#!/usr/bin/env python3

import sqlite3
import json
import logging
import os
import re
import threading
import time
from datetime import datetime
from typing import List, Optional, Dict, Any, Tuple
from dataclasses import dataclass
from pathlib import Path
from utils.logging_config import get_logger

logger = get_logger("music_database")

_database_initialized_paths = set()
_database_sidecar_warnings = set()
_database_initialization_lock = threading.Lock()

# Import matching engine for enhanced similarity logic
try:
    from core.matching_engine import MusicMatchingEngine
    _matching_engine = MusicMatchingEngine()
except ImportError:
    logger.warning("Could not import MusicMatchingEngine, falling back to basic similarity")
    _matching_engine = None

@dataclass
class DatabaseArtist:
    id: int
    name: str
    thumb_url: Optional[str] = None
    genres: Optional[List[str]] = None
    summary: Optional[str] = None
    created_at: Optional[datetime] = None
    updated_at: Optional[datetime] = None

@dataclass
class DatabaseAlbum:
    id: int
    artist_id: int
    title: str
    year: Optional[int] = None
    thumb_url: Optional[str] = None
    genres: Optional[List[str]] = None
    track_count: Optional[int] = None
    duration: Optional[int] = None
    created_at: Optional[datetime] = None
    updated_at: Optional[datetime] = None

@dataclass
class DatabaseTrack:
    id: int
    album_id: int
    artist_id: int
    title: str
    track_number: Optional[int] = None
    duration: Optional[int] = None
    file_path: Optional[str] = None
    bitrate: Optional[int] = None
    created_at: Optional[datetime] = None
    updated_at: Optional[datetime] = None

@dataclass
class DatabaseTrackWithMetadata:
    """Track with joined artist and album names for metadata comparison"""
    id: int
    album_id: int
    artist_id: int
    title: str
    artist_name: str
    album_title: str
    track_number: Optional[int] = None
    duration: Optional[int] = None
    created_at: Optional[datetime] = None
    updated_at: Optional[datetime] = None

@dataclass
class WatchlistArtist:
    """Artist being monitored for new releases"""
    id: int
    spotify_artist_id: Optional[str]  # Can be None if added via iTunes
    artist_name: str
    date_added: datetime
    last_scan_timestamp: Optional[datetime] = None
    created_at: Optional[datetime] = None
    updated_at: Optional[datetime] = None
    image_url: Optional[str] = None
    itunes_artist_id: Optional[str] = None  # Cross-provider support
    deezer_artist_id: Optional[str] = None  # Cross-provider support
    discogs_artist_id: Optional[str] = None  # Cross-provider support
    musicbrainz_artist_id: Optional[str] = None  # Cross-provider support
    include_albums: bool = True
    include_eps: bool = True
    include_singles: bool = True
    include_live: bool = False
    include_remixes: bool = False
    include_acoustic: bool = False
    include_compilations: bool = False
    include_instrumentals: bool = False
    lookback_days: Optional[int] = None  # Per-artist override; None = use global setting
    preferred_metadata_source: Optional[str] = None  # Per-artist override; None = use global setting
    profile_id: int = 1

@dataclass
class SimilarArtist:
    """Similar artist recommendation from Spotify/iTunes/Deezer"""
    id: int
    source_artist_id: str  # Watchlist artist's database ID
    similar_artist_spotify_id: Optional[str]  # Spotify artist ID (may be None if iTunes-only)
    similar_artist_itunes_id: Optional[str]  # iTunes artist ID (may be None if Spotify-only)
    similar_artist_name: str
    similarity_rank: int  # 1-10, where 1 is most similar
    occurrence_count: int  # How many watchlist artists share this similar artist
    last_updated: datetime
    image_url: Optional[str] = None  # Cached artist image
    genres: Optional[List[str]] = None  # Cached genres
    popularity: int = 0  # Cached popularity score
    similar_artist_deezer_id: Optional[str] = None  # Deezer artist ID
    similar_artist_musicbrainz_id: Optional[str] = None  # MusicBrainz artist ID

@dataclass
class DiscoveryTrack:
    """Track in the discovery pool for recommendations"""
    id: int
    spotify_track_id: Optional[str]  # Spotify track ID (None if iTunes source)
    spotify_album_id: Optional[str]  # Spotify album ID (None if iTunes source)
    spotify_artist_id: Optional[str]  # Spotify artist ID (None if iTunes source)
    itunes_track_id: Optional[str]  # iTunes track ID (None if Spotify source)
    itunes_album_id: Optional[str]  # iTunes album ID (None if Spotify source)
    itunes_artist_id: Optional[str]  # iTunes artist ID (None if Spotify source)
    deezer_track_id: Optional[str]  # Deezer track ID (None if non-Deezer source)
    deezer_album_id: Optional[str]  # Deezer album ID (None if non-Deezer source)
    deezer_artist_id: Optional[str]  # Deezer artist ID (None if non-Deezer source)
    source: str  # 'spotify', 'itunes', or 'deezer'
    track_name: str
    artist_name: str
    album_name: str
    album_cover_url: Optional[str]
    duration_ms: int
    popularity: int
    release_date: str
    is_new_release: bool  # Released within last 30 days
    track_data_json: str  # Full track object for modal (Spotify or iTunes format)
    added_date: datetime

@dataclass
class RecentRelease:
    """Recent album release from watchlist artist"""
    id: int
    watchlist_artist_id: int
    album_spotify_id: Optional[str]  # Spotify album ID (None if iTunes source)
    album_itunes_id: Optional[str]  # iTunes album ID (None if Spotify source)
    album_deezer_id: Optional[str]  # Deezer album ID (None if non-Deezer source)
    source: str  # 'spotify', 'itunes', or 'deezer'
    album_name: str
    release_date: str
    album_cover_url: Optional[str]
    track_count: int
    added_date: datetime

class MusicDatabase:
    """SQLite database manager for SoulSync music library data"""
    
    def __init__(self, database_path: str = None):
        # Use env var if path is None OR if it's the default path
        # This ensures Docker containers use the correct mounted volume location
        if database_path is None or database_path == "database/music_library.db":
            database_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
        self.database_path = Path(database_path)
        self.database_path.parent.mkdir(parents=True, exist_ok=True)
        self._warn_about_stale_sqlite_sidecars()
        
        # Initialize database once per process for this path
        self._initialize_database_once()

    def _warn_about_stale_sqlite_sidecars(self):
        """Warn if SQLite sidecars are present and the database looks unhealthy."""
        db_key = str(self.database_path.resolve())
        with _database_initialization_lock:
            if db_key in _database_sidecar_warnings:
                return
            _database_sidecar_warnings.add(db_key)

        wal_path = Path(f"{self.database_path}-wal")
        shm_path = Path(f"{self.database_path}-shm")
        existing = [p.name for p in (wal_path, shm_path) if p.exists()]

        if existing:
            check_result = None
            try:
                conn = sqlite3.connect(f"file:{self.database_path}?mode=ro", uri=True, timeout=5.0)
                try:
                    row = conn.execute("PRAGMA quick_check").fetchone()
                    check_result = row[0] if row else None
                finally:
                    conn.close()
            except Exception as e:
                logger.warning(
                    "SQLite sidecar files detected for %s: %s, and database health check could not be run (%s). "
                    "This usually means the previous shutdown was not clean.",
                    self.database_path,
                    ", ".join(existing),
                    e,
                )
                return

            if check_result != "ok":
                logger.warning(
                    "SQLite sidecar files detected for %s: %s, and quick_check returned %r. "
                    "This usually means the previous shutdown was not clean.",
                    self.database_path,
                    ", ".join(existing),
                    check_result,
                )
            else:
                logger.debug(
                    "SQLite sidecar files present for %s (%s) but quick_check returned ok.",
                    self.database_path,
                    ", ".join(existing),
                )

    def _initialize_database_once(self):
        """Run schema setup and migrations once per database path per process."""
        db_key = str(self.database_path.resolve())

        with _database_initialization_lock:
            if db_key in _database_initialized_paths:
                return

            self._initialize_database()
            _database_initialized_paths.add(db_key)
    
    def _get_connection(self) -> sqlite3.Connection:
        """Get a NEW database connection for each operation (thread-safe)"""
        connection = sqlite3.connect(str(self.database_path), timeout=30.0)
        connection.row_factory = sqlite3.Row
        # Register Unicode-normalizing function for diacritics-aware LIKE queries
        try:
            from unidecode import unidecode as _ud
            connection.create_function("unidecode_lower", 1, lambda x: _ud(x).lower() if x else "")
        except ImportError:
            connection.create_function("unidecode_lower", 1, lambda x: x.lower() if x else "")
        # Enable foreign key constraints and WAL mode for better concurrency
        connection.execute("PRAGMA foreign_keys = ON")
        connection.execute("PRAGMA journal_mode = WAL")
        connection.execute("PRAGMA busy_timeout = 30000")  # 30 second timeout
        return connection
    
    def _initialize_database(self):
        """Create database tables if they don't exist"""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            
            # Artists table
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS artists (
                    id INTEGER PRIMARY KEY,
                    name TEXT NOT NULL,
                    thumb_url TEXT,
                    genres TEXT,  -- JSON array
                    summary TEXT,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)
            
            # Albums table
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS albums (
                    id INTEGER PRIMARY KEY,
                    artist_id INTEGER NOT NULL,
                    title TEXT NOT NULL,
                    year INTEGER,
                    thumb_url TEXT,
                    genres TEXT,  -- JSON array
                    track_count INTEGER,
                    duration INTEGER,  -- milliseconds
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    FOREIGN KEY (artist_id) REFERENCES artists (id) ON DELETE CASCADE
                )
            """)
            
            # Tracks table
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS tracks (
                    id INTEGER PRIMARY KEY,
                    album_id INTEGER NOT NULL,
                    artist_id INTEGER NOT NULL,
                    title TEXT NOT NULL,
                    track_number INTEGER,
                    duration INTEGER,  -- milliseconds
                    file_path TEXT,
                    bitrate INTEGER,
                    file_size INTEGER,  -- bytes; populated by deep scan from media-server API
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE,
                    FOREIGN KEY (artist_id) REFERENCES artists (id) ON DELETE CASCADE
                )
            """)
            
            # Metadata table for storing system information like last refresh dates
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS metadata (
                    key TEXT PRIMARY KEY,
                    value TEXT,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)
            
            # Wishlist table for storing failed download tracks for retry
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS wishlist_tracks (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    spotify_track_id TEXT UNIQUE NOT NULL,
                    spotify_data TEXT NOT NULL,  -- JSON of full Spotify track data
                    failure_reason TEXT,
                    retry_count INTEGER DEFAULT 0,
                    last_attempted TIMESTAMP,
                    date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    source_type TEXT DEFAULT 'unknown',  -- 'playlist', 'album', 'manual'
                    source_info TEXT  -- JSON of source context (playlist name, album info, etc.)
                )
            """)
            
            # Watchlist table for storing artists to monitor for new releases
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS watchlist_artists (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    spotify_artist_id TEXT UNIQUE,
                    itunes_artist_id TEXT,
                    deezer_artist_id TEXT,
                    discogs_artist_id TEXT,
                    musicbrainz_artist_id TEXT,
                    artist_name TEXT NOT NULL,
                    date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    last_scan_timestamp TIMESTAMP,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)

            # Create indexes for performance
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums (artist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks (album_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks (artist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_wishlist_spotify_id ON wishlist_tracks (spotify_track_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_spotify_id ON watchlist_artists (spotify_artist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_wishlist_date_added ON wishlist_tracks (date_added)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_name ON artists (name)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (title)")
            
            # Add server_source columns for multi-server support (migration)
            self._add_server_source_columns(cursor)

            # Migrate ID columns to support both integer (Plex) and string (Jellyfin) IDs
            self._migrate_id_columns_to_text(cursor)

            # Add discovery feature tables (migration)
            self._add_discovery_tables(cursor)

            # Add image_url column to watchlist_artists (migration)
            self._add_watchlist_artist_image_column(cursor)

            # Add album type filter columns to watchlist_artists (migration)
            self._add_watchlist_album_type_filters(cursor)

            # Add content type filter columns to watchlist_artists (migration)
            self._add_watchlist_content_type_filters(cursor)

            # Add per-artist lookback_days column to watchlist_artists (migration)
            self._add_watchlist_lookback_days_column(cursor)

            # Add iTunes artist ID column to watchlist_artists (migration)
            self._add_watchlist_itunes_id_column(cursor)

            # Add per-artist preferred_metadata_source column (migration)
            self._add_watchlist_preferred_metadata_source_column(cursor)

            # Make spotify_artist_id nullable for iTunes-only artists (migration)
            self._fix_watchlist_spotify_id_nullable(cursor)

            # Add MusicBrainz columns to library tables (migration)
            self._add_musicbrainz_columns(cursor)

            # Add external ID columns (Spotify/iTunes) to library tables (migration)
            self._add_external_id_columns(cursor)

            # Add AudioDB columns to artists table (migration)
            self._add_audiodb_columns(cursor)

            # Add Deezer columns to library tables (migration)
            self._add_deezer_columns(cursor)

            # Add Spotify/iTunes enrichment tracking columns (migration)
            self._add_spotify_itunes_enrichment_columns(cursor)

            # Add Last.fm and Genius enrichment columns (migration)
            self._add_lastfm_genius_columns(cursor)

            # Add Tidal and Qobuz enrichment columns (migration)
            self._add_tidal_qobuz_enrichment_columns(cursor)

            # Add Discogs enrichment columns (migration)
            self._add_discogs_columns(cursor)

            # Add Amazon artist ID column (migration)
            self._add_amazon_columns(cursor)

            # Backfill match_status for rows that already have an external ID but
            # NULL status. Prevents enrichment workers from re-processing these
            # rows forever. Must run AFTER all *_match_status columns have been
            # created by the migrations above.
            self._backfill_match_status_for_existing_ids(cursor)

            # Bubble snapshots table for persisting UI state across page refreshes
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS bubble_snapshots (
                    type TEXT PRIMARY KEY,
                    data TEXT NOT NULL,
                    timestamp TEXT NOT NULL,
                    snapshot_id TEXT NOT NULL
                )
            """)

            # Add last_featured column to similar_artists for hero cycling (migration)
            self._add_similar_artists_last_featured_column(cursor)

            # Retag tool tables for tracking processed downloads (migration)
            self._add_retag_tables(cursor)

            # Multi-profile support (migration)
            self._add_profile_support(cursor)
            self._add_profile_support_v2(cursor)
            self._add_profile_support_v3(cursor)
            self._add_profile_support_v4(cursor)
            self._add_profile_settings(cursor)
            self._add_profile_listenbrainz_support(cursor)
            self._add_profile_service_credentials(cursor)
            self._add_soul_id_columns(cursor)
            self._add_listening_history_table(cursor)

            # Spotify library cache
            self._add_spotify_library_cache_table(cursor)

            # Universal metadata cache (Spotify + iTunes API responses)
            self._add_metadata_cache_tables(cursor)

            # Repair worker v2 tables (findings + job runs)
            self._add_repair_worker_tables(cursor)

            # Mirrored playlists — persistent backup of parsed playlists from any service
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS mirrored_playlists (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    source TEXT NOT NULL,
                    source_playlist_id TEXT NOT NULL,
                    name TEXT NOT NULL,
                    description TEXT,
                    owner TEXT,
                    image_url TEXT,
                    track_count INTEGER DEFAULT 0,
                    profile_id INTEGER DEFAULT 1,
                    mirrored_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE(source, source_playlist_id, profile_id)
                )
            """)
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS mirrored_playlist_tracks (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    playlist_id INTEGER NOT NULL,
                    position INTEGER NOT NULL,
                    track_name TEXT NOT NULL,
                    artist_name TEXT NOT NULL,
                    album_name TEXT DEFAULT '',
                    duration_ms INTEGER DEFAULT 0,
                    image_url TEXT,
                    source_track_id TEXT,
                    extra_data TEXT,
                    FOREIGN KEY (playlist_id) REFERENCES mirrored_playlists(id) ON DELETE CASCADE,
                    UNIQUE(playlist_id, position)
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mirrored_playlists_profile ON mirrored_playlists (profile_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mirrored_playlists_source ON mirrored_playlists (source, source_playlist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mirrored_tracks_playlist ON mirrored_playlist_tracks (playlist_id)")

            # Automations table — trigger → action scheduled tasks
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS automations (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    name TEXT NOT NULL,
                    enabled INTEGER DEFAULT 1,
                    trigger_type TEXT NOT NULL,
                    trigger_config TEXT DEFAULT '{}',
                    action_type TEXT NOT NULL,
                    action_config TEXT DEFAULT '{}',
                    last_run TIMESTAMP,
                    next_run TIMESTAMP,
                    run_count INTEGER DEFAULT 0,
                    last_error TEXT,
                    profile_id INTEGER DEFAULT 1,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_automations_profile ON automations (profile_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_automations_enabled ON automations (enabled)")

            # Automation run history table
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS automation_run_history (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    automation_id INTEGER NOT NULL,
                    started_at TIMESTAMP,
                    finished_at TIMESTAMP,
                    duration_seconds REAL,
                    status TEXT NOT NULL,
                    summary TEXT,
                    result_json TEXT,
                    log_lines TEXT,
                    FOREIGN KEY (automation_id) REFERENCES automations(id) ON DELETE CASCADE
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_arh_automation_id ON automation_run_history(automation_id)")

            # Add explored_at to mirrored_playlists (migration)
            self._add_mirrored_playlist_explored_column(cursor)

            # Add notification columns to automations (migration)
            self._add_automation_notify_columns(cursor)
            self._add_automation_system_column(cursor)
            self._add_automation_then_actions_column(cursor)
            self._add_automation_group_name_column(cursor)

            # Library issues — user-reported problems with tracks/albums/artists
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS library_issues (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    profile_id INTEGER NOT NULL DEFAULT 1,
                    entity_type TEXT NOT NULL,
                    entity_id TEXT NOT NULL,
                    category TEXT NOT NULL,
                    title TEXT NOT NULL,
                    description TEXT,
                    snapshot_data TEXT DEFAULT '{}',
                    status TEXT NOT NULL DEFAULT 'open',
                    priority TEXT NOT NULL DEFAULT 'normal',
                    admin_response TEXT,
                    resolved_by INTEGER,
                    resolved_at TIMESTAMP,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    FOREIGN KEY (profile_id) REFERENCES profiles (id) ON DELETE CASCADE
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_profile ON library_issues (profile_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_status ON library_issues (status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_entity ON library_issues (entity_type, entity_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_created ON library_issues (created_at)")

            # Library history — persistent log of downloads and server imports
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS library_history (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    event_type TEXT NOT NULL,
                    title TEXT NOT NULL,
                    artist_name TEXT,
                    album_name TEXT,
                    quality TEXT,
                    server_source TEXT,
                    file_path TEXT,
                    thumb_url TEXT,
                    download_source TEXT,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_lh_event_type ON library_history (event_type)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_lh_created_at ON library_history (created_at DESC)")

            # Migration: add download_source column
            cursor.execute("PRAGMA table_info(library_history)")
            lh_cols = {c[1] for c in cursor.fetchall()}
            if 'download_source' not in lh_cols:
                cursor.execute("ALTER TABLE library_history ADD COLUMN download_source TEXT")
                logger.info("Added download_source column to library_history")
            for _col in ['source_track_id', 'source_track_title', 'source_filename', 'acoustid_result', 'source_artist']:
                if _col not in lh_cols:
                    cursor.execute(f"ALTER TABLE library_history ADD COLUMN {_col} TEXT")
                    logger.info(f"Added {_col} column to library_history")

            # Auto-import history — tracks auto-import scan results and processing status
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS auto_import_history (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    folder_name TEXT NOT NULL,
                    folder_path TEXT NOT NULL,
                    folder_hash TEXT,
                    status TEXT NOT NULL DEFAULT 'scanning',
                    confidence REAL DEFAULT 0.0,
                    album_id TEXT,
                    album_name TEXT,
                    artist_name TEXT,
                    image_url TEXT,
                    total_files INTEGER DEFAULT 0,
                    matched_files INTEGER DEFAULT 0,
                    match_data TEXT,
                    identification_method TEXT,
                    error_message TEXT,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    processed_at TIMESTAMP
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_aih_status ON auto_import_history (status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_aih_folder_hash ON auto_import_history (folder_hash)")

            # Sync history table — tracks the last 100 sync operations with cached context for re-trigger
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS sync_history (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    batch_id TEXT NOT NULL,
                    playlist_id TEXT,
                    playlist_name TEXT NOT NULL,
                    source TEXT NOT NULL,
                    sync_type TEXT NOT NULL,
                    artist_context TEXT,
                    album_context TEXT,
                    tracks_json TEXT NOT NULL,
                    total_tracks INTEGER DEFAULT 0,
                    tracks_found INTEGER DEFAULT 0,
                    tracks_downloaded INTEGER DEFAULT 0,
                    tracks_failed INTEGER DEFAULT 0,
                    thumb_url TEXT,
                    is_album_download INTEGER DEFAULT 0,
                    playlist_folder_mode INTEGER DEFAULT 0,
                    started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    completed_at TIMESTAMP,
                    track_results TEXT
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_sh_started_at ON sync_history (started_at DESC)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_sh_source ON sync_history (source)")

            # Migration: add track_results column to existing sync_history tables
            try:
                cursor.execute("SELECT track_results FROM sync_history LIMIT 1")
            except Exception:
                try:
                    cursor.execute("ALTER TABLE sync_history ADD COLUMN track_results TEXT")
                    logger.info("Added track_results column to sync_history table")
                except Exception as e:
                    logger.debug("Failed to add track_results column: %s", e)

            # Migration: add source_page column to sync_history (UI origin context for batch panel)
            try:
                cursor.execute("SELECT source_page FROM sync_history LIMIT 1")
            except Exception:
                try:
                    cursor.execute("ALTER TABLE sync_history ADD COLUMN source_page TEXT")
                    logger.info("Added source_page column to sync_history table")
                except Exception as e:
                    logger.debug("Failed to add source_page column: %s", e)

            # 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 as e:
                    logger.debug("Failed to add track_artist column: %s", e)

            # Migration: add file_size column so the Stats page can show
            # total library size on disk without having to walk the
            # filesystem on every request. Populated by the deep scan from
            # whatever the media server reports (Plex MediaPart.size,
            # Jellyfin MediaSources[].Size, Navidrome <song size="...">,
            # SoulSync standalone os.path.getsize). NULL on existing rows
            # until the next deep scan fills them in — UI handles the
            # NULL case by showing "(run a Deep Scan to populate)".
            try:
                cursor.execute("SELECT file_size FROM tracks LIMIT 1")
            except Exception:
                try:
                    cursor.execute("ALTER TABLE tracks ADD COLUMN file_size INTEGER")
                    logger.info("Added file_size column to tracks table")
                except Exception as e:
                    logger.debug("Failed to add file_size column: %s", e)

            # One-time migration: purge discovery cache entries that lack track_number.
            # Prior versions cached discovery results without track_number/disc_number/release_date,
            # causing incorrect file organization (all tracks as "01", missing album year).
            # Purged entries get re-populated with complete data on next discovery.
            try:
                cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_discovery_cache_v2_migrated'")
                if not cursor.fetchone():
                    cursor.execute("DELETE FROM discovery_match_cache WHERE id IN ("
                                   "SELECT id FROM discovery_match_cache WHERE "
                                   "matched_data_json NOT LIKE '%track_number%')")
                    purged = cursor.rowcount
                    cursor.execute("CREATE TABLE _discovery_cache_v2_migrated (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
                    if purged > 0:
                        logger.info(f"Purged {purged} stale discovery cache entries (missing track_number)")
            except Exception as e:
                logger.debug("Failed to purge stale discovery cache entries: %s", e)

            # One-time migration: purge Deezer album/track cache entries with missing data.
            # Deezer's /artist/{id}/albums returns albums without artist info, and search
            # results cache tracks without track_position — both produce bad metadata.
            try:
                cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_deezer_cache_v2_migrated'")
                if not cursor.fetchone():
                    cursor.execute("""DELETE FROM metadata_cache_entities
                                     WHERE source = 'deezer' AND entity_type IN ('album', 'track')""")
                    purged = cursor.rowcount
                    cursor.execute("""DELETE FROM metadata_cache_searches
                                     WHERE source = 'deezer' AND search_type IN ('album', 'track')""")
                    cursor.execute("CREATE TABLE _deezer_cache_v2_migrated (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
                    if purged > 0:
                        logger.info(f"Purged {purged} stale Deezer cache entries (missing artist/track_position)")
            except Exception as e:
                logger.debug("Failed to purge stale Deezer cache entries: %s", e)

            # One-time migration: purge cached tracks/albums with junk artist names.
            # The cache gate now rejects these, but existing entries need cleaning.
            try:
                cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_cache_junk_artist_purged'")
                if not cursor.fetchone():
                    cursor.execute("""DELETE FROM metadata_cache_entities
                                     WHERE entity_type IN ('track', 'album')
                                       AND (artist_name IS NULL
                                         OR TRIM(artist_name) = ''
                                         OR LOWER(TRIM(artist_name)) IN ('unknown', 'unknown artist', 'none', 'null'))""")
                    purged = cursor.rowcount
                    cursor.execute("CREATE TABLE _cache_junk_artist_purged (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
                    if purged > 0:
                        logger.info(f"Purged {purged} cached tracks/albums with junk artist names")
            except Exception as e:
                logger.debug("Failed to purge cached tracks/albums with junk artist names: %s", e)

            # HiFi API instances table
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS hifi_instances (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    url TEXT NOT NULL UNIQUE,
                    priority INTEGER NOT NULL DEFAULT 0,
                    enabled INTEGER NOT NULL DEFAULT 1,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)

            # Personalized-playlists subsystem schema (Group A + Group B
            # unified storage). Idempotent — safe on every startup.
            try:
                from database.personalized_schema import ensure_personalized_schema
                ensure_personalized_schema(conn)
            except Exception as ps_err:
                logger.error(f"Personalized-playlist schema init failed: {ps_err}")

            self._ensure_core_media_schema_columns(cursor)

            conn.commit()
            logger.info("Database initialized successfully")

        except Exception as e:
            logger.error(f"Error initializing database: {e}")
            raise

        self._init_manual_library_match_table()

    def _ensure_core_media_schema_columns(self, cursor):
        """Repair required media-library columns that older migrations may miss.

        A few legacy migrations rebuild artists/albums/tracks in place. Newer
        installs get these columns from CREATE TABLE, but upgraded databases can
        occasionally miss one if a previous migration path failed or was marked
        complete before the column existed.
        """
        try:
            cursor.execute("PRAGMA table_info(tracks)")
            track_cols = {c[1] for c in cursor.fetchall()}
            if track_cols and 'file_size' not in track_cols:
                cursor.execute("ALTER TABLE tracks ADD COLUMN file_size INTEGER")
                logger.info("Repaired missing file_size column on tracks table")

            cursor.execute("PRAGMA table_info(albums)")
            album_cols = {c[1] for c in cursor.fetchall()}
            if album_cols and 'api_track_count' not in album_cols:
                cursor.execute("ALTER TABLE albums ADD COLUMN api_track_count INTEGER DEFAULT NULL")
                logger.info("Repaired missing api_track_count column on albums table")
        except Exception as e:
            logger.error("Error repairing core media schema columns: %s", e)

    def _add_mirrored_playlist_explored_column(self, cursor):
        """Add explored_at column to mirrored_playlists to persist explore badge."""
        try:
            cursor.execute("PRAGMA table_info(mirrored_playlists)")
            cols = [c[1] for c in cursor.fetchall()]
            if 'explored_at' not in cols:
                cursor.execute("ALTER TABLE mirrored_playlists ADD COLUMN explored_at TIMESTAMP DEFAULT NULL")
                logger.info("Added explored_at column to mirrored_playlists table")
        except Exception as e:
            logger.error(f"Error adding explored_at column to mirrored_playlists: {e}")

    def _add_automation_notify_columns(self, cursor):
        """Add notification and result columns to automations table."""
        try:
            cursor.execute("PRAGMA table_info(automations)")
            cols = [c[1] for c in cursor.fetchall()]
            for col, typedef in [('notify_type', 'TEXT DEFAULT NULL'), ('notify_config', "TEXT DEFAULT '{}'"), ('last_result', 'TEXT DEFAULT NULL')]:
                if col not in cols:
                    cursor.execute(f"ALTER TABLE automations ADD COLUMN {col} {typedef}")
                    logger.info(f"Added {col} column to automations table")
        except Exception as e:
            logger.error(f"Error adding automation notify columns: {e}")

    def _add_automation_system_column(self, cursor):
        """Add is_system column to automations table for non-deletable system automations."""
        try:
            cursor.execute("PRAGMA table_info(automations)")
            cols = [c[1] for c in cursor.fetchall()]
            if 'is_system' not in cols:
                cursor.execute("ALTER TABLE automations ADD COLUMN is_system INTEGER DEFAULT 0")
                logger.info("Added is_system column to automations table")
        except Exception as e:
            logger.error(f"Error adding automation system column: {e}")

    def _add_automation_group_name_column(self, cursor):
        """Add group_name column to automations table for folder-style grouping."""
        try:
            cursor.execute("PRAGMA table_info(automations)")
            cols = [c[1] for c in cursor.fetchall()]
            if 'group_name' not in cols:
                cursor.execute("ALTER TABLE automations ADD COLUMN group_name TEXT DEFAULT NULL")
                logger.info("Added group_name column to automations table")
        except Exception as e:
            logger.error(f"Error adding automation group_name column: {e}")

    def _add_automation_then_actions_column(self, cursor):
        """Add then_actions column to automations table and migrate existing notify data."""
        try:
            cursor.execute("PRAGMA table_info(automations)")
            cols = [c[1] for c in cursor.fetchall()]
            if 'then_actions' not in cols:
                cursor.execute("ALTER TABLE automations ADD COLUMN then_actions TEXT DEFAULT '[]'")
                logger.info("Added then_actions column to automations table")
                # Migrate existing notify_type/notify_config into then_actions
                cursor.execute("SELECT id, notify_type, notify_config FROM automations WHERE notify_type IS NOT NULL AND notify_type != ''")
                for row in cursor.fetchall():
                    try:
                        config = json.loads(row[2]) if row[2] else {}
                        then_actions = json.dumps([{'type': row[1], 'config': config}])
                        cursor.execute("UPDATE automations SET then_actions = ? WHERE id = ?", (then_actions, row[0]))
                    except Exception as e:
                        logger.debug("Failed to migrate notify data for automation row: %s", e)
                logger.info("Migrated existing notify data to then_actions")
        except Exception as e:
            logger.error(f"Error adding automation then_actions column: {e}")

    def _add_server_source_columns(self, cursor):
        """Add server_source columns to existing tables for multi-server support"""
        try:
            # Check if server_source column exists in artists table
            cursor.execute("PRAGMA table_info(artists)")
            artists_columns = [column[1] for column in cursor.fetchall()]
            
            if 'server_source' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN server_source TEXT DEFAULT 'plex'")
                logger.info("Added server_source column to artists table")
            
            # Check if server_source column exists in albums table
            cursor.execute("PRAGMA table_info(albums)")
            albums_columns = [column[1] for column in cursor.fetchall()]
            
            if 'server_source' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN server_source TEXT DEFAULT 'plex'")
                logger.info("Added server_source column to albums table")
            
            # Check if server_source column exists in tracks table
            cursor.execute("PRAGMA table_info(tracks)")
            tracks_columns = [column[1] for column in cursor.fetchall()]
            
            if 'server_source' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN server_source TEXT DEFAULT 'plex'")
                logger.info("Added server_source column to tracks table")
            if 'disc_number' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN disc_number INTEGER DEFAULT 1")
                logger.info("Added disc_number column to tracks table")
                
            # Create indexes for server_source columns for performance
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_server_source ON artists (server_source)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_server_source ON albums (server_source)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_server_source ON tracks (server_source)")
            
        except Exception as e:
            logger.error(f"Error adding server_source columns: {e}")
            # Don't raise - this is a migration, database can still function without it
    
    def _migrate_id_columns_to_text(self, cursor):
        """Migrate ID columns from INTEGER to TEXT to support both Plex (int) and Jellyfin (GUID) IDs"""
        try:
            # Check if migration has already been applied by looking for a specific marker
            cursor.execute("SELECT value FROM metadata WHERE key = 'id_columns_migrated' LIMIT 1")
            migration_done = cursor.fetchone()
            
            if migration_done:
                logger.debug("ID columns migration already applied")
                return
            
            logger.info("Migrating ID columns to support both integer and string IDs...")
            
            # SQLite doesn't support changing column types directly, so we need to recreate tables
            # This is a complex migration - let's do it safely
            
            # Step 1: Create new tables with TEXT IDs
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS artists_new (
                    id TEXT PRIMARY KEY,
                    name TEXT NOT NULL,
                    thumb_url TEXT,
                    genres TEXT,
                    summary TEXT,
                    server_source TEXT DEFAULT 'plex',
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)
            
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS albums_new (
                    id TEXT PRIMARY KEY,
                    artist_id TEXT NOT NULL,
                    title TEXT NOT NULL,
                    year INTEGER,
                    thumb_url TEXT,
                    genres TEXT,
                    track_count INTEGER,
                    duration INTEGER,
                    server_source TEXT DEFAULT 'plex',
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    FOREIGN KEY (artist_id) REFERENCES artists_new (id) ON DELETE CASCADE
                )
            """)
            
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS tracks_new (
                    id TEXT PRIMARY KEY,
                    album_id TEXT NOT NULL,
                    artist_id TEXT NOT NULL,
                    title TEXT NOT NULL,
                    track_number INTEGER,
                    duration INTEGER,
                    file_path TEXT,
                    bitrate INTEGER,
                    server_source TEXT DEFAULT 'plex',
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    FOREIGN KEY (album_id) REFERENCES albums_new (id) ON DELETE CASCADE,
                    FOREIGN KEY (artist_id) REFERENCES artists_new (id) ON DELETE CASCADE
                )
            """)
            
            # Step 2: Copy existing data (converting INTEGER IDs to TEXT)
            cursor.execute("""
                INSERT INTO artists_new (id, name, thumb_url, genres, summary, server_source, created_at, updated_at)
                SELECT CAST(id AS TEXT), name, thumb_url, genres, summary, 
                       COALESCE(server_source, 'plex'), created_at, updated_at 
                FROM artists
            """)
            
            cursor.execute("""
                INSERT INTO albums_new (id, artist_id, title, year, thumb_url, genres, track_count, duration, server_source, created_at, updated_at)
                SELECT CAST(id AS TEXT), CAST(artist_id AS TEXT), title, year, thumb_url, genres, track_count, duration,
                       COALESCE(server_source, 'plex'), created_at, updated_at
                FROM albums
            """)
            
            cursor.execute("""
                INSERT INTO tracks_new (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, created_at, updated_at)
                SELECT CAST(id AS TEXT), CAST(album_id AS TEXT), CAST(artist_id AS TEXT), title, track_number, duration, file_path, bitrate,
                       COALESCE(server_source, 'plex'), created_at, updated_at
                FROM tracks
            """)
            
            # Step 3: Drop old tables and rename new ones
            cursor.execute("DROP TABLE IF EXISTS tracks")
            cursor.execute("DROP TABLE IF EXISTS albums") 
            cursor.execute("DROP TABLE IF EXISTS artists")
            
            cursor.execute("ALTER TABLE artists_new RENAME TO artists")
            cursor.execute("ALTER TABLE albums_new RENAME TO albums")
            cursor.execute("ALTER TABLE tracks_new RENAME TO tracks")
            
            # Step 4: Recreate indexes
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums (artist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks (album_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks (artist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_server_source ON artists (server_source)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_server_source ON albums (server_source)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_server_source ON tracks (server_source)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_name ON artists (name)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (title)")
            
            # Step 5: Mark migration as complete
            cursor.execute("""
                INSERT OR REPLACE INTO metadata (key, value, updated_at) 
                VALUES ('id_columns_migrated', 'true', CURRENT_TIMESTAMP)
            """)
            
            logger.info("ID columns migration completed successfully")
            
        except Exception as e:
            logger.error(f"Error migrating ID columns: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_discovery_tables(self, cursor):
        """Add tables for discovery feature: similar artists, discovery pool, and recent releases"""
        try:
            # Similar Artists table - stores similar artists for each watchlist artist
            # Supports Spotify plus fallback provider IDs for discovery
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS similar_artists (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    source_artist_id TEXT NOT NULL,
                    similar_artist_spotify_id TEXT,
                    similar_artist_itunes_id TEXT,
                    similar_artist_name TEXT NOT NULL,
                    similarity_rank INTEGER DEFAULT 1,
                    occurrence_count INTEGER DEFAULT 1,
                    last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE(source_artist_id, similar_artist_name)
                )
            """)

            # Discovery Pool table - rotating pool of 1000-2000 tracks for recommendations
            # Supports Spotify, iTunes, and Deezer sources for discovery
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS discovery_pool (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    spotify_track_id TEXT,
                    spotify_album_id TEXT,
                    spotify_artist_id TEXT,
                    itunes_track_id TEXT,
                    itunes_album_id TEXT,
                    itunes_artist_id TEXT,
                    source TEXT NOT NULL DEFAULT 'spotify',
                    track_name TEXT NOT NULL,
                    artist_name TEXT NOT NULL,
                    album_name TEXT NOT NULL,
                    album_cover_url TEXT,
                    duration_ms INTEGER,
                    popularity INTEGER DEFAULT 0,
                    release_date TEXT,
                    is_new_release BOOLEAN DEFAULT 0,
                    track_data_json TEXT NOT NULL,
                    added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE(spotify_track_id, itunes_track_id, source)
                )
            """)

            # Recent Releases table - tracks new releases from watchlist artists
            # Supports Spotify, iTunes, and Deezer sources for discovery
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS recent_releases (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    watchlist_artist_id INTEGER NOT NULL,
                    album_spotify_id TEXT,
                    album_itunes_id TEXT,
                    source TEXT NOT NULL DEFAULT 'spotify',
                    album_name TEXT NOT NULL,
                    release_date TEXT NOT NULL,
                    album_cover_url TEXT,
                    track_count INTEGER DEFAULT 0,
                    added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE(watchlist_artist_id, album_spotify_id, album_itunes_id),
                    FOREIGN KEY (watchlist_artist_id) REFERENCES watchlist_artists (id) ON DELETE CASCADE
                )
            """)

            # Discovery Recent Albums cache - for discover page recent releases section
            # Supports Spotify, iTunes, and Deezer sources for discovery
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS discovery_recent_albums (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    album_spotify_id TEXT,
                    album_itunes_id TEXT,
                    artist_spotify_id TEXT,
                    artist_itunes_id TEXT,
                    source TEXT NOT NULL DEFAULT 'spotify',
                    album_name TEXT NOT NULL,
                    artist_name TEXT NOT NULL,
                    album_cover_url TEXT,
                    release_date TEXT NOT NULL,
                    album_type TEXT DEFAULT 'album',
                    cached_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE(album_spotify_id, album_itunes_id, source)
                )
            """)

            # Discovery Curated Playlists - store curated track selections for consistency
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS discovery_curated_playlists (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    playlist_type TEXT NOT NULL UNIQUE,
                    track_ids_json TEXT NOT NULL,
                    curated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)

            # Discovery Pool Metadata - track when pool was last populated to prevent over-polling
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS discovery_pool_metadata (
                    id INTEGER PRIMARY KEY CHECK (id = 1),
                    last_populated_timestamp TIMESTAMP NOT NULL,
                    track_count INTEGER DEFAULT 0,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)

            # ListenBrainz Playlists - cache playlists from ListenBrainz
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS listenbrainz_playlists (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    playlist_mbid TEXT NOT NULL UNIQUE,
                    title TEXT NOT NULL,
                    creator TEXT,
                    playlist_type TEXT NOT NULL,
                    track_count INTEGER DEFAULT 0,
                    annotation_data TEXT,
                    last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    cached_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)

            # ListenBrainz Tracks - cache tracks for each playlist
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS listenbrainz_tracks (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    playlist_id INTEGER NOT NULL,
                    position INTEGER NOT NULL,
                    track_name TEXT NOT NULL,
                    artist_name TEXT NOT NULL,
                    album_name TEXT NOT NULL,
                    duration_ms INTEGER DEFAULT 0,
                    recording_mbid TEXT,
                    release_mbid TEXT,
                    album_cover_url TEXT,
                    additional_metadata TEXT,
                    FOREIGN KEY (playlist_id) REFERENCES listenbrainz_playlists (id) ON DELETE CASCADE,
                    UNIQUE(playlist_id, position)
                )
            """)

            # ============== MIGRATIONS (must run BEFORE index creation on new columns) ==============

            # Add genres column to discovery_pool if it doesn't exist (migration)
            cursor.execute("PRAGMA table_info(discovery_pool)")
            discovery_pool_columns = [column[1] for column in cursor.fetchall()]

            if 'artist_genres' not in discovery_pool_columns:
                cursor.execute("ALTER TABLE discovery_pool ADD COLUMN artist_genres TEXT")
                logger.info("Added artist_genres column to discovery_pool table")

            if 'source' not in discovery_pool_columns:
                cursor.execute("ALTER TABLE discovery_pool ADD COLUMN source TEXT DEFAULT 'spotify'")
                logger.info("Added source column to discovery_pool table")

            # Migration: Add iTunes columns to discovery_pool for dual-source discovery
            if 'itunes_track_id' not in discovery_pool_columns:
                cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_track_id TEXT")
                cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_album_id TEXT")
                cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_artist_id TEXT")
                logger.info("Added iTunes columns to discovery_pool table for dual-source discovery")

            # Migration: Add Deezer columns to discovery_pool for tri-source discovery
            if 'deezer_track_id' not in discovery_pool_columns:
                cursor.execute("ALTER TABLE discovery_pool ADD COLUMN deezer_track_id TEXT")
                cursor.execute("ALTER TABLE discovery_pool ADD COLUMN deezer_album_id TEXT")
                cursor.execute("ALTER TABLE discovery_pool ADD COLUMN deezer_artist_id TEXT")
                logger.info("Added Deezer columns to discovery_pool table")

            # Migration: Add iTunes ID to similar_artists for dual-source discovery
            cursor.execute("PRAGMA table_info(similar_artists)")
            similar_artists_columns = [column[1] for column in cursor.fetchall()]

            if 'similar_artist_itunes_id' not in similar_artists_columns:
                cursor.execute("ALTER TABLE similar_artists ADD COLUMN similar_artist_itunes_id TEXT")
                logger.info("Added similar_artist_itunes_id column to similar_artists table")

            if 'similar_artist_deezer_id' not in similar_artists_columns:
                cursor.execute("ALTER TABLE similar_artists ADD COLUMN similar_artist_deezer_id TEXT")
                logger.info("Added similar_artist_deezer_id column to similar_artists table")

            if 'similar_artist_musicbrainz_id' not in similar_artists_columns:
                cursor.execute("ALTER TABLE similar_artists ADD COLUMN similar_artist_musicbrainz_id TEXT")
                logger.info("Added similar_artist_musicbrainz_id column to similar_artists table")

            # Migration: Add iTunes columns to recent_releases for dual-source discovery
            cursor.execute("PRAGMA table_info(recent_releases)")
            recent_releases_columns = [column[1] for column in cursor.fetchall()]

            if 'source' not in recent_releases_columns:
                cursor.execute("ALTER TABLE recent_releases ADD COLUMN source TEXT DEFAULT 'spotify'")
                logger.info("Added source column to recent_releases table")

            if 'album_itunes_id' not in recent_releases_columns:
                cursor.execute("ALTER TABLE recent_releases ADD COLUMN album_itunes_id TEXT")
                logger.info("Added iTunes columns to recent_releases table for dual-source discovery")

            # Migration: Add Deezer column to recent_releases for tri-source discovery
            if 'album_deezer_id' not in recent_releases_columns:
                cursor.execute("ALTER TABLE recent_releases ADD COLUMN album_deezer_id TEXT")
                logger.info("Added album_deezer_id column to recent_releases table")

            # Migration: Add iTunes columns to discovery_recent_albums for dual-source discovery
            cursor.execute("PRAGMA table_info(discovery_recent_albums)")
            discovery_recent_albums_columns = [column[1] for column in cursor.fetchall()]

            if 'source' not in discovery_recent_albums_columns:
                cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN source TEXT DEFAULT 'spotify'")
                logger.info("Added source column to discovery_recent_albums table")

            if 'album_itunes_id' not in discovery_recent_albums_columns:
                cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN album_itunes_id TEXT")
                cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN artist_itunes_id TEXT")
                logger.info("Added iTunes columns to discovery_recent_albums table for dual-source discovery")

            # Migration: Add Deezer columns to discovery_recent_albums for tri-source discovery
            if 'album_deezer_id' not in discovery_recent_albums_columns:
                cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN album_deezer_id TEXT")
                cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN artist_deezer_id TEXT")
                logger.info("Added Deezer columns to discovery_recent_albums table")

            # Migration: Fix NOT NULL constraint on album_spotify_id (required for iTunes-only albums)
            # Check if album_spotify_id has NOT NULL constraint by checking table schema
            cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='discovery_recent_albums'")
            table_schema = cursor.fetchone()
            if table_schema and 'album_spotify_id TEXT NOT NULL' in (table_schema[0] or ''):
                logger.info("Migrating discovery_recent_albums to allow NULL album_spotify_id for iTunes support...")
                # SQLite doesn't support ALTER COLUMN, so recreate table
                cursor.execute("PRAGMA table_info(discovery_recent_albums)")
                old_cols_info = cursor.fetchall()
                old_col_names = [c[1] for c in old_cols_info]
                cursor.execute("""
                    CREATE TABLE IF NOT EXISTS discovery_recent_albums_new (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        album_spotify_id TEXT,
                        album_itunes_id TEXT,
                        album_deezer_id TEXT,
                        artist_spotify_id TEXT,
                        artist_itunes_id TEXT,
                        artist_deezer_id TEXT,
                        source TEXT NOT NULL DEFAULT 'spotify',
                        album_name TEXT NOT NULL,
                        artist_name TEXT NOT NULL,
                        album_cover_url TEXT,
                        release_date TEXT NOT NULL,
                        album_type TEXT DEFAULT 'album',
                        cached_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                        UNIQUE(album_spotify_id, album_itunes_id, album_deezer_id, source)
                    )
                """)
                new_cols = ['id', 'album_spotify_id', 'album_itunes_id', 'album_deezer_id',
                            'artist_spotify_id', 'artist_itunes_id', 'artist_deezer_id',
                            'source', 'album_name', 'artist_name', 'album_cover_url',
                            'release_date', 'album_type', 'cached_date']
                shared_cols = [c for c in new_cols if c in old_col_names]
                cols_str = ', '.join(shared_cols)
                cursor.execute(f"INSERT OR IGNORE INTO discovery_recent_albums_new ({cols_str}) SELECT {cols_str} FROM discovery_recent_albums")
                cursor.execute("DROP TABLE discovery_recent_albums")
                cursor.execute("ALTER TABLE discovery_recent_albums_new RENAME TO discovery_recent_albums")
                cursor.connection.commit()
                logger.info("Successfully migrated discovery_recent_albums table for iTunes support")

            # Migration: Add UNIQUE constraint to similar_artists table
            # Skip if table already has profile-scoped UNIQUE constraint (from v3 migration)
            cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='similar_artists'")
            sa_create_sql = cursor.fetchone()
            has_profile_unique = sa_create_sql and 'UNIQUE(profile_id' in (sa_create_sql[0] or '')

            if not has_profile_unique:
                # Test if ON CONFLICT works by trying a dummy operation
                needs_similar_migration = False
                try:
                    cursor.execute("""
                        INSERT INTO similar_artists
                        (source_artist_id, similar_artist_name, similarity_rank, occurrence_count, last_updated)
                        VALUES ('__migration_test__', '__migration_test__', 1, 1, CURRENT_TIMESTAMP)
                        ON CONFLICT(source_artist_id, similar_artist_name)
                        DO UPDATE SET occurrence_count = occurrence_count
                    """)
                    # Clean up test row
                    cursor.execute("DELETE FROM similar_artists WHERE source_artist_id = '__migration_test__'")
                    logger.info("similar_artists table has correct UNIQUE constraint")
                except Exception as constraint_error:
                    logger.info(f"similar_artists needs migration (constraint test failed: {constraint_error})")
                    needs_similar_migration = True

                if needs_similar_migration:
                    logger.info("Migrating similar_artists to add UNIQUE constraint...")
                    # Get a fresh connection for the migration
                    with self._get_connection() as migration_conn:
                        migration_cursor = migration_conn.cursor()
                        # SQLite doesn't support adding constraints, so recreate table
                        migration_cursor.execute("DROP TABLE IF EXISTS similar_artists_new")
                        migration_cursor.execute("""
                            CREATE TABLE similar_artists_new (
                                id INTEGER PRIMARY KEY AUTOINCREMENT,
                                source_artist_id TEXT NOT NULL,
                                similar_artist_spotify_id TEXT,
                                similar_artist_itunes_id TEXT,
                                similar_artist_deezer_id TEXT,
                                similar_artist_musicbrainz_id TEXT,
                                similar_artist_name TEXT NOT NULL,
                                similarity_rank INTEGER DEFAULT 1,
                                occurrence_count INTEGER DEFAULT 1,
                                last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                                UNIQUE(source_artist_id, similar_artist_name)
                            )
                        """)
                        migration_cursor.execute("""
                            INSERT OR IGNORE INTO similar_artists_new
                            (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id,
                             similar_artist_deezer_id, similar_artist_musicbrainz_id,
                             similar_artist_name, similarity_rank, occurrence_count, last_updated)
                            SELECT source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id,
                                   similar_artist_deezer_id, similar_artist_musicbrainz_id,
                                   similar_artist_name, similarity_rank, occurrence_count, last_updated
                            FROM similar_artists
                        """)
                        migration_cursor.execute("DROP TABLE similar_artists")
                        migration_cursor.execute("ALTER TABLE similar_artists_new RENAME TO similar_artists")
                        migration_conn.commit()
                        logger.info("Successfully migrated similar_artists table with UNIQUE constraint")

            # ============== INDEXES (after migrations to ensure columns exist) ==============
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_source ON similar_artists (source_artist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_spotify ON similar_artists (similar_artist_spotify_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_itunes ON similar_artists (similar_artist_itunes_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_musicbrainz ON similar_artists (similar_artist_musicbrainz_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_occurrence ON similar_artists (occurrence_count)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_name ON similar_artists (similar_artist_name)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_spotify_track ON discovery_pool (spotify_track_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_itunes_track ON discovery_pool (itunes_track_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_artist ON discovery_pool (spotify_artist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_itunes_artist ON discovery_pool (itunes_artist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_deezer_track ON discovery_pool (deezer_track_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_deezer_artist ON discovery_pool (deezer_artist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_source ON discovery_pool (source)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_added_date ON discovery_pool (added_date)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_is_new ON discovery_pool (is_new_release)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_watchlist ON recent_releases (watchlist_artist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_date ON recent_releases (release_date)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_source ON recent_releases (source)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_source ON discovery_recent_albums (source)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_date ON discovery_recent_albums (release_date)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_playlists_type ON listenbrainz_playlists (playlist_type)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_playlists_mbid ON listenbrainz_playlists (playlist_mbid)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_tracks_playlist ON listenbrainz_tracks (playlist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_tracks_position ON listenbrainz_tracks (playlist_id, position)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_artist ON discovery_recent_albums (artist_spotify_id)")

            # Discovery Match Cache - caches successful discovery matches across all sources
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS discovery_match_cache (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    normalized_title TEXT NOT NULL,
                    normalized_artist TEXT NOT NULL,
                    provider TEXT NOT NULL,
                    match_confidence REAL NOT NULL,
                    matched_data_json TEXT NOT NULL,
                    original_title TEXT,
                    original_artist TEXT,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    use_count INTEGER DEFAULT 1,
                    UNIQUE(normalized_title, normalized_artist, provider)
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_cache_lookup ON discovery_match_cache (normalized_title, normalized_artist, provider)")

            # Sync match cache — caches server track ID for discovered Spotify tracks
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS sync_match_cache (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    spotify_track_id TEXT NOT NULL,
                    normalized_title TEXT NOT NULL,
                    normalized_artist TEXT NOT NULL,
                    server_source TEXT NOT NULL,
                    server_track_id INTEGER NOT NULL,
                    server_track_title TEXT,
                    confidence REAL NOT NULL,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    use_count INTEGER DEFAULT 1,
                    UNIQUE(spotify_track_id, server_source)
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_sync_cache_lookup ON sync_match_cache (spotify_track_id, server_source)")

            # Download blacklist — tracks users have rejected as wrong matches
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS download_blacklist (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    track_title TEXT,
                    track_artist TEXT,
                    blocked_filename TEXT,
                    blocked_username TEXT,
                    reason TEXT DEFAULT 'user_rejected',
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE(blocked_username, blocked_filename)
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_blacklist_user_file ON download_blacklist (blocked_username, blocked_filename)")

            # Track download provenance — where each library track came from
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS track_downloads (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    track_id TEXT,
                    file_path TEXT,
                    source_service TEXT NOT NULL,
                    source_username TEXT,
                    source_filename TEXT,
                    source_size INTEGER,
                    audio_quality TEXT,
                    track_title TEXT,
                    track_artist TEXT,
                    track_album TEXT,
                    status TEXT DEFAULT 'completed',
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_track_id ON track_downloads (track_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_file_path ON track_downloads (file_path)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_source ON track_downloads (source_username, source_filename)")

            # Migration: Add audio detail columns to track_downloads
            cursor.execute("PRAGMA table_info(track_downloads)")
            td_columns = [c[1] for c in cursor.fetchall()]
            if 'bit_depth' not in td_columns:
                cursor.execute("ALTER TABLE track_downloads ADD COLUMN bit_depth INTEGER")
                cursor.execute("ALTER TABLE track_downloads ADD COLUMN sample_rate INTEGER")
                cursor.execute("ALTER TABLE track_downloads ADD COLUMN bitrate INTEGER")
                logger.info("Added audio detail columns (bit_depth, sample_rate, bitrate) to track_downloads")

            # Migration: Add external metadata-source ID columns to
            # track_downloads. Persists the IDs we already collect at
            # post-processing time so the watchlist scanner + media-server
            # sync backfill can read them without waiting for the async
            # enrichment workers.
            external_id_cols = [
                'spotify_track_id', 'itunes_track_id', 'deezer_track_id',
                'tidal_track_id', 'qobuz_track_id', 'musicbrainz_recording_id',
                'audiodb_id', 'soul_id', 'isrc',
            ]
            added_external = False
            for _col in external_id_cols:
                if _col not in td_columns:
                    cursor.execute(f"ALTER TABLE track_downloads ADD COLUMN {_col} TEXT")
                    added_external = True
            if added_external:
                logger.info(f"Added external-ID columns to track_downloads: {', '.join(external_id_cols)}")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_spotify_id ON track_downloads (spotify_track_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_itunes_id ON track_downloads (itunes_track_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_deezer_id ON track_downloads (deezer_track_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_tidal_id ON track_downloads (tidal_track_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_qobuz_id ON track_downloads (qobuz_track_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_mbid ON track_downloads (musicbrainz_recording_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_audiodb_id ON track_downloads (audiodb_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_soul_id ON track_downloads (soul_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_isrc ON track_downloads (isrc)")

            # Persistent MusicBrainz album → release-MBID cache.
            # Backs `core/metadata/album_mbid_cache.py`. Keyed by the same
            # (normalized_album_key, artist_key) shape the in-memory
            # `mb_release_cache` uses, so a successful lookup remembered
            # ONCE applies to every future track of the same album for
            # the install's lifetime. Solves the "tracks of one album get
            # different release MBIDs after cache eviction / restart"
            # issue that causes Navidrome to split albums.
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS mb_album_release_cache (
                    normalized_album_key TEXT NOT NULL,
                    artist_key TEXT NOT NULL,
                    release_mbid TEXT NOT NULL,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    PRIMARY KEY (normalized_album_key, artist_key)
                )
            """)
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_mb_album_release_mbid "
                "ON mb_album_release_cache (release_mbid)"
            )

            # Discovery artist blacklist — artists users never want to see in discovery
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS discovery_artist_blacklist (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    artist_name TEXT NOT NULL COLLATE NOCASE,
                    spotify_artist_id TEXT,
                    itunes_artist_id TEXT,
                    deezer_artist_id TEXT,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE(artist_name)
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_dab_name ON discovery_artist_blacklist (artist_name COLLATE NOCASE)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_dab_spotify ON discovery_artist_blacklist (spotify_artist_id)")

            # Liked artists pool — aggregated followed/liked artists from connected services
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS liked_artists_pool (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    artist_name TEXT NOT NULL,
                    normalized_name TEXT NOT NULL,
                    spotify_artist_id TEXT,
                    itunes_artist_id TEXT,
                    deezer_artist_id TEXT,
                    discogs_artist_id TEXT,
                    musicbrainz_artist_id TEXT,
                    image_url TEXT,
                    genres TEXT,
                    source_services TEXT DEFAULT '[]',
                    active_source_id TEXT,
                    active_source TEXT,
                    match_status TEXT DEFAULT 'pending',
                    on_watchlist INTEGER DEFAULT 0,
                    profile_id INTEGER DEFAULT 1,
                    last_fetched_at TIMESTAMP,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE(profile_id, normalized_name)
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_profile ON liked_artists_pool (profile_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_status ON liked_artists_pool (profile_id, match_status)")
            cursor.execute("PRAGMA table_info(liked_artists_pool)")
            liked_artist_columns = {column[1] for column in cursor.fetchall()}
            if 'musicbrainz_artist_id' not in liked_artist_columns:
                cursor.execute("ALTER TABLE liked_artists_pool ADD COLUMN musicbrainz_artist_id TEXT")

            # Liked albums pool — aggregated saved/liked albums from connected services
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS liked_albums_pool (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    album_name TEXT NOT NULL,
                    artist_name TEXT NOT NULL,
                    normalized_key TEXT NOT NULL,
                    spotify_album_id TEXT,
                    tidal_album_id TEXT,
                    deezer_album_id TEXT,
                    discogs_release_id TEXT,
                    image_url TEXT,
                    release_date TEXT,
                    total_tracks INTEGER DEFAULT 0,
                    source_services TEXT DEFAULT '[]',
                    profile_id INTEGER DEFAULT 1,
                    last_fetched_at TIMESTAMP,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE(profile_id, normalized_key)
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_lalp_profile ON liked_albums_pool (profile_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_lalp_spotify ON liked_albums_pool (spotify_album_id)")

            # Migration: add discogs_release_id column for the Discogs
            # collection source on the Your Albums section. Idempotent —
            # safe on existing installs that already have the table.
            try:
                cursor.execute("SELECT discogs_release_id FROM liked_albums_pool LIMIT 1")
            except Exception:
                try:
                    cursor.execute("ALTER TABLE liked_albums_pool ADD COLUMN discogs_release_id TEXT")
                    logger.info("Added discogs_release_id column to liked_albums_pool")
                except Exception as e:
                    logger.debug("Failed to add discogs_release_id column: %s", e)

            logger.info("Discovery tables added/verified successfully")

        except Exception as e:
            logger.error(f"Error creating discovery tables: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_watchlist_artist_image_column(self, cursor):
        """Add image_url column to watchlist_artists table"""
        try:
            cursor.execute("PRAGMA table_info(watchlist_artists)")
            columns = [column[1] for column in cursor.fetchall()]

            if 'image_url' not in columns:
                cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN image_url TEXT")
                logger.info("Added image_url column to watchlist_artists table")

        except Exception as e:
            logger.error(f"Error adding image_url column to watchlist_artists: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_watchlist_album_type_filters(self, cursor):
        """Add album type filter columns to watchlist_artists table"""
        try:
            cursor.execute("PRAGMA table_info(watchlist_artists)")
            columns = [column[1] for column in cursor.fetchall()]

            columns_to_add = {
                'include_albums': ('INTEGER', '1'),     # 1 = True (include albums)
                'include_eps': ('INTEGER', '1'),        # 1 = True (include EPs)
                'include_singles': ('INTEGER', '1')     # 1 = True (include singles)
            }

            for column_name, (column_type, default_value) in columns_to_add.items():
                if column_name not in columns:
                    cursor.execute(f"ALTER TABLE watchlist_artists ADD COLUMN {column_name} {column_type} DEFAULT {default_value}")
                    logger.info(f"Added {column_name} column to watchlist_artists table")

        except Exception as e:
            logger.error(f"Error adding album type filter columns to watchlist_artists: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_watchlist_content_type_filters(self, cursor):
        """Add content type filter columns to watchlist_artists table"""
        try:
            cursor.execute("PRAGMA table_info(watchlist_artists)")
            columns = [column[1] for column in cursor.fetchall()]

            columns_to_add = {
                'include_live': ('INTEGER', '0'),          # 0 = False (exclude live versions by default)
                'include_remixes': ('INTEGER', '0'),        # 0 = False (exclude remixes by default)
                'include_acoustic': ('INTEGER', '0'),       # 0 = False (exclude acoustic by default)
                'include_compilations': ('INTEGER', '0'),   # 0 = False (exclude compilations by default)
                'include_instrumentals': ('INTEGER', '0')   # 0 = False (exclude instrumentals by default)
            }

            for column_name, (column_type, default_value) in columns_to_add.items():
                if column_name not in columns:
                    cursor.execute(f"ALTER TABLE watchlist_artists ADD COLUMN {column_name} {column_type} DEFAULT {default_value}")
                    logger.info(f"Added {column_name} column to watchlist_artists table")

        except Exception as e:
            logger.error(f"Error adding content type filter columns to watchlist_artists: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_watchlist_lookback_days_column(self, cursor):
        """Add per-artist lookback_days column to watchlist_artists table"""
        try:
            cursor.execute("PRAGMA table_info(watchlist_artists)")
            columns = [column[1] for column in cursor.fetchall()]
            if 'lookback_days' not in columns:
                cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN lookback_days INTEGER DEFAULT NULL")
                logger.info("Added lookback_days column to watchlist_artists table")
        except Exception as e:
            logger.error(f"Error adding lookback_days column to watchlist_artists: {e}")

    def _add_watchlist_itunes_id_column(self, cursor):
        """Add iTunes artist ID column to watchlist_artists table for cross-provider support"""
        try:
            cursor.execute("PRAGMA table_info(watchlist_artists)")
            columns = [column[1] for column in cursor.fetchall()]

            if 'itunes_artist_id' not in columns:
                cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN itunes_artist_id TEXT")
                logger.info("Added itunes_artist_id column to watchlist_artists table for cross-provider support")

            if 'deezer_artist_id' not in columns:
                cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN deezer_artist_id TEXT")
                logger.info("Added deezer_artist_id column to watchlist_artists table for cross-provider support")

            if 'discogs_artist_id' not in columns:
                cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN discogs_artist_id TEXT")
                logger.info("Added discogs_artist_id column to watchlist_artists table for cross-provider support")

            if 'amazon_artist_id' not in columns:
                cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN amazon_artist_id TEXT")
                logger.info("Added amazon_artist_id column to watchlist_artists table for Amazon Music support")

            if 'musicbrainz_artist_id' not in columns:
                cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN musicbrainz_artist_id TEXT")
                logger.info("Added musicbrainz_artist_id column to watchlist_artists table for MusicBrainz support")

        except Exception as e:
            logger.error(f"Error adding itunes_artist_id column to watchlist_artists: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_watchlist_preferred_metadata_source_column(self, cursor):
        """Add per-artist preferred_metadata_source column to watchlist_artists table"""
        try:
            cursor.execute("PRAGMA table_info(watchlist_artists)")
            columns = [column[1] for column in cursor.fetchall()]
            if 'preferred_metadata_source' not in columns:
                cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN preferred_metadata_source TEXT DEFAULT NULL")
                logger.info("Added preferred_metadata_source column to watchlist_artists table")
        except Exception as e:
            logger.error(f"Error adding preferred_metadata_source column to watchlist_artists: {e}")

    def _add_similar_artists_last_featured_column(self, cursor):
        """Add last_featured column to similar_artists for hero slider cycling"""
        try:
            cursor.execute("PRAGMA table_info(similar_artists)")
            columns = [column[1] for column in cursor.fetchall()]

            if 'last_featured' not in columns:
                cursor.execute("ALTER TABLE similar_artists ADD COLUMN last_featured TIMESTAMP")
                logger.info("Added last_featured column to similar_artists table for hero cycling")

            # Migration: Add cached metadata columns to avoid API calls on every page load
            if 'image_url' not in columns:
                cursor.execute("ALTER TABLE similar_artists ADD COLUMN image_url TEXT")
                cursor.execute("ALTER TABLE similar_artists ADD COLUMN genres TEXT")
                cursor.execute("ALTER TABLE similar_artists ADD COLUMN popularity INTEGER DEFAULT 0")
                cursor.execute("ALTER TABLE similar_artists ADD COLUMN metadata_updated_at TIMESTAMP")
                logger.info("Added image_url, genres, popularity, metadata_updated_at columns to similar_artists for hero caching")

        except Exception as e:
            logger.error(f"Error adding columns to similar_artists: {e}")
            # Don't raise - this is a migration, database can still function

    def _fix_watchlist_spotify_id_nullable(self, cursor):
        """
        Make spotify_artist_id nullable in watchlist_artists table.
        This allows adding iTunes-only artists without Spotify IDs.

        Since SQLite doesn't support modifying column constraints directly,
        we need to recreate the table if the constraint needs to be changed.
        """
        try:
            # Check if spotify_artist_id is currently NOT NULL using PRAGMA
            # (more reliable than string-matching the CREATE TABLE SQL)
            cursor.execute("PRAGMA table_info(watchlist_artists)")
            columns = {col[1]: col for col in cursor.fetchall()}
            spotify_col = columns.get('spotify_artist_id')

            # notnull flag is index 3 in PRAGMA table_info
            has_not_null = spotify_col and spotify_col[3] == 1

            if has_not_null:
                logger.info("Migrating watchlist_artists table to make spotify_artist_id nullable...")

                # Check if old table already has profile_id (from profile migration)
                old_has_profile = 'profile_id' in columns

                # Drop leftover temp table from any previous failed migration
                cursor.execute("DROP TABLE IF EXISTS watchlist_artists_new")

                # Create new table with nullable spotify_artist_id
                # Include profile_id + composite UNIQUE if old table had profile support
                if old_has_profile:
                    cursor.execute("""
                        CREATE TABLE watchlist_artists_new (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            spotify_artist_id TEXT,
                            artist_name TEXT NOT NULL,
                            date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            last_scan_timestamp TIMESTAMP,
                            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            image_url TEXT,
                            include_albums INTEGER DEFAULT 1,
                            include_eps INTEGER DEFAULT 1,
                            include_singles INTEGER DEFAULT 1,
                            include_live INTEGER DEFAULT 0,
                            include_remixes INTEGER DEFAULT 0,
                            include_acoustic INTEGER DEFAULT 0,
                            include_compilations INTEGER DEFAULT 0,
                            include_instrumentals INTEGER DEFAULT 0,
                            lookback_days INTEGER DEFAULT NULL,
                            itunes_artist_id TEXT,
                            deezer_artist_id TEXT,
                            discogs_artist_id TEXT,
                            musicbrainz_artist_id TEXT,
                            profile_id INTEGER DEFAULT 1,
                            UNIQUE(profile_id, spotify_artist_id),
                            UNIQUE(profile_id, itunes_artist_id)
                        )
                    """)
                else:
                    cursor.execute("""
                        CREATE TABLE watchlist_artists_new (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            spotify_artist_id TEXT UNIQUE,
                            artist_name TEXT NOT NULL,
                            date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            last_scan_timestamp TIMESTAMP,
                            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            image_url TEXT,
                            include_albums INTEGER DEFAULT 1,
                            include_eps INTEGER DEFAULT 1,
                            include_singles INTEGER DEFAULT 1,
                            include_live INTEGER DEFAULT 0,
                            include_remixes INTEGER DEFAULT 0,
                            include_acoustic INTEGER DEFAULT 0,
                            include_compilations INTEGER DEFAULT 0,
                            include_instrumentals INTEGER DEFAULT 0,
                            lookback_days INTEGER DEFAULT NULL,
                            itunes_artist_id TEXT,
                            deezer_artist_id TEXT,
                            discogs_artist_id TEXT,
                            musicbrainz_artist_id TEXT
                        )
                    """)

                # Copy data from old table (only columns that exist in both)
                cursor.execute("PRAGMA table_info(watchlist_artists)")
                old_cols = [col[1] for col in cursor.fetchall()]
                new_cols = ['id', 'spotify_artist_id', 'artist_name', 'date_added',
                            'last_scan_timestamp', 'created_at', 'updated_at', 'image_url',
                            'include_albums', 'include_eps', 'include_singles', 'include_live',
                            'include_remixes', 'include_acoustic', 'include_compilations',
                            'include_instrumentals', 'lookback_days',
                            'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id',
                            'musicbrainz_artist_id', 'profile_id']
                shared_cols = [c for c in new_cols if c in old_cols]
                cols_str = ', '.join(shared_cols)
                cursor.execute(f"INSERT INTO watchlist_artists_new ({cols_str}) SELECT {cols_str} FROM watchlist_artists")

                # Drop old table
                cursor.execute("DROP TABLE watchlist_artists")

                # Rename new table
                cursor.execute("ALTER TABLE watchlist_artists_new RENAME TO watchlist_artists")

                # Recreate indexes
                cursor.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_spotify_id ON watchlist_artists (spotify_artist_id)")
                if old_has_profile:
                    cursor.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_profile ON watchlist_artists (profile_id)")
                
                logger.info("Successfully migrated watchlist_artists table - spotify_artist_id is now nullable")
            else:
                logger.debug("watchlist_artists table already has nullable spotify_artist_id or custom schema")
                
        except Exception as e:
            logger.error(f"Error making spotify_artist_id nullable in watchlist_artists: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_musicbrainz_columns(self, cursor):
        """Add MusicBrainz tracking columns to library tables for metadata enrichment"""
        columns_added = False
        try:
            # --- Artists ---
            cursor.execute("PRAGMA table_info(artists)")
            artists_columns = [column[1] for column in cursor.fetchall()]

            if 'musicbrainz_id' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN musicbrainz_id TEXT")
                columns_added = True
            if 'musicbrainz_last_attempted' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN musicbrainz_last_attempted TIMESTAMP")
                columns_added = True
            if 'musicbrainz_match_status' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN musicbrainz_match_status TEXT")
                columns_added = True
            # MusicBrainz exposes alternate-spelling aliases on every artist
            # record (Japanese kanji ↔ romanized, Cyrillic ↔ Latin, etc.).
            # SoulSync's artist matching used to compare expected vs actual
            # name with raw similarity — cross-script comparison scored 0%
            # and the file got quarantined even when MusicBrainz knew both
            # names belonged to the same artist (issue #442). Persist the
            # alias list as JSON so the verifier + matcher can consult it
            # without re-querying MB on every comparison.
            if 'aliases' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN aliases TEXT")
                columns_added = True
            if columns_added:
                logger.info("Added MusicBrainz columns to artists table")

            # --- Albums ---
            cursor.execute("PRAGMA table_info(albums)")
            albums_columns = [column[1] for column in cursor.fetchall()]

            added_albums = False
            if 'musicbrainz_release_id' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN musicbrainz_release_id TEXT")
                added_albums = True
            if 'musicbrainz_last_attempted' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN musicbrainz_last_attempted TIMESTAMP")
                added_albums = True
            if 'musicbrainz_match_status' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN musicbrainz_match_status TEXT")
                added_albums = True
            if added_albums:
                columns_added = True
                logger.info("Added MusicBrainz columns to albums table")

            # --- Tracks ---
            cursor.execute("PRAGMA table_info(tracks)")
            tracks_columns = [column[1] for column in cursor.fetchall()]

            added_tracks = False
            if 'musicbrainz_recording_id' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN musicbrainz_recording_id TEXT")
                added_tracks = True
            if 'musicbrainz_last_attempted' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN musicbrainz_last_attempted TIMESTAMP")
                added_tracks = True
            if 'musicbrainz_match_status' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN musicbrainz_match_status TEXT")
                added_tracks = True
            if added_tracks:
                columns_added = True
                logger.info("Added MusicBrainz columns to tracks table")
            
            # Create MusicBrainz cache table for storing API results
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS musicbrainz_cache (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    entity_type TEXT NOT NULL,
                    entity_name TEXT NOT NULL,
                    artist_name TEXT,
                    musicbrainz_id TEXT,
                    spotify_id TEXT,
                    itunes_id TEXT,
                    metadata_json TEXT,
                    match_confidence INTEGER,
                    last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE(entity_type, entity_name, artist_name)
                )
            """)
            
            # Create indexes (safe even if columns were already present)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists (musicbrainz_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_mb_status ON artists (musicbrainz_match_status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_mbid ON albums (musicbrainz_release_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_mb_status ON albums (musicbrainz_match_status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_mbid ON tracks (musicbrainz_recording_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_mb_status ON tracks (musicbrainz_match_status)")
            
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mb_cache_entity ON musicbrainz_cache (entity_type, entity_name)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mb_cache_mbid ON musicbrainz_cache (musicbrainz_id)")
            # Partial index for failed lookups — speeds up the management modal queries
            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:
                logger.info("MusicBrainz migration completed successfully")
            
        except Exception as e:
            logger.error(f"Error in MusicBrainz migration: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_external_id_columns(self, cursor):
        """Add Spotify/iTunes external ID columns to library tables for enrichment"""
        try:
            # --- Artists ---
            cursor.execute("PRAGMA table_info(artists)")
            artists_columns = [column[1] for column in cursor.fetchall()]

            if 'spotify_artist_id' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN spotify_artist_id TEXT")
            if 'itunes_artist_id' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN itunes_artist_id TEXT")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_spotify_id ON artists (spotify_artist_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_itunes_id ON artists (itunes_artist_id)")

            # --- Albums ---
            cursor.execute("PRAGMA table_info(albums)")
            albums_columns = [column[1] for column in cursor.fetchall()]

            if 'spotify_album_id' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN spotify_album_id TEXT")
            if 'itunes_album_id' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN itunes_album_id TEXT")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_spotify_id ON albums (spotify_album_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_itunes_id ON albums (itunes_album_id)")

            # --- Tracks ---
            cursor.execute("PRAGMA table_info(tracks)")
            tracks_columns = [column[1] for column in cursor.fetchall()]

            if 'spotify_track_id' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN spotify_track_id TEXT")
            if 'itunes_track_id' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN itunes_track_id TEXT")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_spotify_id ON tracks (spotify_track_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_itunes_id ON tracks (itunes_track_id)")

        except Exception as e:
            logger.error(f"Error adding external ID columns: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_audiodb_columns(self, cursor):
        """Add AudioDB tracking + generic metadata columns for enrichment (artists, albums, tracks)"""
        try:
            # --- Artists ---
            cursor.execute("PRAGMA table_info(artists)")
            artists_columns = [column[1] for column in cursor.fetchall()]

            if 'audiodb_id' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN audiodb_id TEXT")
            if 'audiodb_match_status' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN audiodb_match_status TEXT")
            if 'audiodb_last_attempted' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN audiodb_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_audiodb_id ON artists (audiodb_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_audiodb_status ON artists (audiodb_match_status)")

            if 'style' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN style TEXT")
            if 'mood' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN mood TEXT")
            if 'label' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN label TEXT")
            if 'banner_url' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN banner_url TEXT")

            # --- Albums ---
            cursor.execute("PRAGMA table_info(albums)")
            albums_columns = [column[1] for column in cursor.fetchall()]

            if 'audiodb_id' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN audiodb_id TEXT")
            if 'audiodb_match_status' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN audiodb_match_status TEXT")
            if 'audiodb_last_attempted' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN audiodb_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_audiodb_id ON albums (audiodb_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_audiodb_status ON albums (audiodb_match_status)")

            if 'style' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN style TEXT")
            if 'mood' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN mood TEXT")

            # --- Tracks ---
            cursor.execute("PRAGMA table_info(tracks)")
            tracks_columns = [column[1] for column in cursor.fetchall()]

            if 'audiodb_id' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN audiodb_id TEXT")
            if 'audiodb_match_status' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN audiodb_match_status TEXT")
            if 'audiodb_last_attempted' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN audiodb_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_audiodb_id ON tracks (audiodb_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_audiodb_status ON tracks (audiodb_match_status)")

            if 'style' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN style TEXT")
            if 'mood' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN mood TEXT")

        except Exception as e:
            logger.error(f"Error adding AudioDB columns: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_discogs_columns(self, cursor):
        """Add Discogs enrichment columns to artists and albums tables."""
        try:
            # --- Artists ---
            cursor.execute("PRAGMA table_info(artists)")
            artists_columns = [column[1] for column in cursor.fetchall()]

            for col in ['discogs_id', 'discogs_match_status', 'discogs_bio', 'discogs_members', 'discogs_urls']:
                if col not in artists_columns:
                    col_type = 'TIMESTAMP' if col.endswith('_attempted') else 'TEXT'
                    cursor.execute(f"ALTER TABLE artists ADD COLUMN {col} {col_type}")
            if 'discogs_last_attempted' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN discogs_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_discogs_id ON artists (discogs_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_discogs_status ON artists (discogs_match_status)")

            # --- Albums ---
            cursor.execute("PRAGMA table_info(albums)")
            albums_columns = [column[1] for column in cursor.fetchall()]

            for col in ['discogs_id', 'discogs_match_status', 'discogs_genres', 'discogs_styles',
                         'discogs_label', 'discogs_catno', 'discogs_country']:
                if col not in albums_columns:
                    cursor.execute(f"ALTER TABLE albums ADD COLUMN {col} TEXT")
            if 'discogs_last_attempted' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN discogs_last_attempted TIMESTAMP")
            if 'discogs_rating' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN discogs_rating REAL")
            if 'discogs_rating_count' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN discogs_rating_count INTEGER")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_discogs_id ON albums (discogs_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_discogs_status ON albums (discogs_match_status)")

            logger.info("Discogs enrichment columns added/verified successfully")

        except Exception as e:
            logger.error(f"Error adding Discogs columns: {e}")

    def _add_amazon_columns(self, cursor):
        """Add Amazon enrichment tracking columns to artists, albums, and tracks."""
        try:
            # --- Artists ---
            cursor.execute("PRAGMA table_info(artists)")
            artists_columns = [column[1] for column in cursor.fetchall()]

            if 'amazon_id' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN amazon_id TEXT")
            if 'amazon_match_status' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN amazon_match_status TEXT")
            if 'amazon_last_attempted' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN amazon_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_id ON artists (amazon_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_status ON artists (amazon_match_status)")

            # --- Albums ---
            cursor.execute("PRAGMA table_info(albums)")
            albums_columns = [column[1] for column in cursor.fetchall()]

            if 'amazon_id' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN amazon_id TEXT")
            if 'amazon_match_status' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN amazon_match_status TEXT")
            if 'amazon_last_attempted' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN amazon_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_amazon_id ON albums (amazon_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_amazon_status ON albums (amazon_match_status)")

            # --- Tracks ---
            cursor.execute("PRAGMA table_info(tracks)")
            tracks_columns = [column[1] for column in cursor.fetchall()]

            if 'amazon_id' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN amazon_id TEXT")
            if 'amazon_match_status' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN amazon_match_status TEXT")
            if 'amazon_last_attempted' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN amazon_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_amazon_id ON tracks (amazon_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_amazon_status ON tracks (amazon_match_status)")

            logger.info("Amazon columns added/verified successfully")
        except Exception as e:
            logger.error(f"Error adding Amazon columns: {e}")

    def _backfill_match_status_for_existing_ids(self, cursor):
        """Set `<provider>_match_status = 'matched'` for rows that already have a
        populated external ID but NULL match_status.

        Prevents enrichment workers from re-selecting the same rows forever when
        the ID was populated outside the worker (file tags, manual match,
        pre-migration legacy data) without a corresponding status update.

        Only runs columns that actually exist, so pre-migration databases are
        handled safely. UPDATE statements are cheap no-ops when nothing matches.
        """
        # (table, id_column, status_column)
        targets = [
            ('artists', 'lastfm_url', 'lastfm_match_status'),
            ('albums', 'lastfm_url', 'lastfm_match_status'),
            ('tracks', 'lastfm_url', 'lastfm_match_status'),
            ('artists', 'musicbrainz_id', 'musicbrainz_match_status'),
            ('albums', 'musicbrainz_release_id', 'musicbrainz_match_status'),
            ('tracks', 'musicbrainz_recording_id', 'musicbrainz_match_status'),
            ('artists', 'tidal_id', 'tidal_match_status'),
            ('albums', 'tidal_id', 'tidal_match_status'),
            ('tracks', 'tidal_id', 'tidal_match_status'),
            ('artists', 'qobuz_id', 'qobuz_match_status'),
            ('albums', 'qobuz_id', 'qobuz_match_status'),
            ('tracks', 'qobuz_id', 'qobuz_match_status'),
        ]

        total_backfilled = 0
        for table, id_col, status_col in targets:
            try:
                cursor.execute(f"PRAGMA table_info({table})")
                cols = {row[1] for row in cursor.fetchall()}
                if id_col not in cols or status_col not in cols:
                    continue
                cursor.execute(
                    f"UPDATE {table} SET {status_col} = 'matched' "
                    f"WHERE {status_col} IS NULL AND {id_col} IS NOT NULL AND {id_col} != ''"
                )
                if cursor.rowcount and cursor.rowcount > 0:
                    total_backfilled += cursor.rowcount
                    logger.info(
                        f"Backfilled {cursor.rowcount} rows in {table}.{status_col} "
                        f"where {id_col} was already set."
                    )
            except Exception as e:
                logger.error(f"Error backfilling {table}.{status_col}: {e}")

        if total_backfilled == 0:
            logger.debug("Match-status backfill: no rows needed updating.")

    def _add_deezer_columns(self, cursor):
        """Add Deezer tracking + generic metadata columns for enrichment (artists, albums, tracks)"""
        try:
            # --- Artists ---
            cursor.execute("PRAGMA table_info(artists)")
            artists_columns = [column[1] for column in cursor.fetchall()]

            if 'deezer_id' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN deezer_id TEXT")
            if 'deezer_match_status' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN deezer_match_status TEXT")
            if 'deezer_last_attempted' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN deezer_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_deezer_id ON artists (deezer_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_deezer_status ON artists (deezer_match_status)")

            # --- Albums ---
            cursor.execute("PRAGMA table_info(albums)")
            albums_columns = [column[1] for column in cursor.fetchall()]

            if 'deezer_id' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN deezer_id TEXT")
            if 'deezer_match_status' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN deezer_match_status TEXT")
            if 'deezer_last_attempted' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN deezer_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_deezer_id ON albums (deezer_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_deezer_status ON albums (deezer_match_status)")

            if 'label' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN label TEXT")
            if 'explicit' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN explicit INTEGER")
            if 'record_type' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN record_type TEXT")

            # --- Tracks ---
            cursor.execute("PRAGMA table_info(tracks)")
            tracks_columns = [column[1] for column in cursor.fetchall()]

            if 'deezer_id' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN deezer_id TEXT")
            if 'deezer_match_status' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN deezer_match_status TEXT")
            if 'deezer_last_attempted' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN deezer_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_deezer_id ON tracks (deezer_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_deezer_status ON tracks (deezer_match_status)")

            if 'bpm' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN bpm REAL")
            if 'explicit' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN explicit INTEGER")

        except Exception as e:
            logger.error(f"Error adding Deezer columns: {e}")
            # Don't raise - this is a migration, database can still function

        # --- Repair worker columns ---
        try:
            if 'repair_status' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN repair_status TEXT")
            if 'repair_last_checked' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN repair_last_checked TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_repair_status ON tracks (repair_status)")

        except Exception as e:
            logger.error(f"Error adding repair columns: {e}")

    def _add_spotify_itunes_enrichment_columns(self, cursor):
        """Add Spotify/iTunes enrichment tracking columns (match_status + last_attempted) to artists, albums, tracks"""
        try:
            # --- Artists ---
            cursor.execute("PRAGMA table_info(artists)")
            artists_columns = [column[1] for column in cursor.fetchall()]

            if 'spotify_match_status' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN spotify_match_status TEXT")
            if 'spotify_last_attempted' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN spotify_last_attempted TIMESTAMP")
            if 'itunes_match_status' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN itunes_match_status TEXT")
            if 'itunes_last_attempted' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN itunes_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_spotify_match_status ON artists (spotify_match_status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_itunes_match_status ON artists (itunes_match_status)")

            # --- Albums ---
            cursor.execute("PRAGMA table_info(albums)")
            albums_columns = [column[1] for column in cursor.fetchall()]

            if 'spotify_match_status' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN spotify_match_status TEXT")
            if 'spotify_last_attempted' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN spotify_last_attempted TIMESTAMP")
            if 'itunes_match_status' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN itunes_match_status TEXT")
            if 'itunes_last_attempted' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN itunes_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_spotify_match_status ON albums (spotify_match_status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_itunes_match_status ON albums (itunes_match_status)")

            # --- Tracks ---
            cursor.execute("PRAGMA table_info(tracks)")
            tracks_columns = [column[1] for column in cursor.fetchall()]

            if 'spotify_match_status' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN spotify_match_status TEXT")
            if 'spotify_last_attempted' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN spotify_last_attempted TIMESTAMP")
            if 'itunes_match_status' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN itunes_match_status TEXT")
            if 'itunes_last_attempted' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN itunes_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_spotify_match_status ON tracks (spotify_match_status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_itunes_match_status ON tracks (itunes_match_status)")

        except Exception as e:
            logger.error(f"Error adding Spotify/iTunes enrichment columns: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_lastfm_genius_columns(self, cursor):
        """Add Last.fm and Genius enrichment tracking + metadata columns to artists, albums, tracks"""
        try:
            # --- Artists ---
            cursor.execute("PRAGMA table_info(artists)")
            artists_columns = [column[1] for column in cursor.fetchall()]

            # Last.fm columns
            if 'lastfm_match_status' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_match_status TEXT")
            if 'lastfm_last_attempted' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_last_attempted TIMESTAMP")
            if 'lastfm_listeners' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_listeners INTEGER")
            if 'lastfm_playcount' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_playcount INTEGER")
            if 'lastfm_tags' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_tags TEXT")
            if 'lastfm_similar' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_similar TEXT")
            if 'lastfm_bio' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_bio TEXT")
            if 'lastfm_url' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_url TEXT")

            # Genius columns
            if 'genius_id' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN genius_id TEXT")
            if 'genius_match_status' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN genius_match_status TEXT")
            if 'genius_last_attempted' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN genius_last_attempted TIMESTAMP")
            if 'genius_description' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN genius_description TEXT")
            if 'genius_alt_names' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN genius_alt_names TEXT")
            if 'genius_url' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN genius_url TEXT")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_lastfm_status ON artists (lastfm_match_status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_genius_id ON artists (genius_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_genius_status ON artists (genius_match_status)")

            # --- Albums ---
            cursor.execute("PRAGMA table_info(albums)")
            albums_columns = [column[1] for column in cursor.fetchall()]

            # Last.fm columns
            if 'lastfm_match_status' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_match_status TEXT")
            if 'lastfm_last_attempted' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_last_attempted TIMESTAMP")
            if 'lastfm_listeners' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_listeners INTEGER")
            if 'lastfm_playcount' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_playcount INTEGER")
            if 'lastfm_tags' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_tags TEXT")
            if 'lastfm_wiki' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_wiki TEXT")
            if 'lastfm_url' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_url TEXT")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_lastfm_status ON albums (lastfm_match_status)")

            # --- Tracks ---
            cursor.execute("PRAGMA table_info(tracks)")
            tracks_columns = [column[1] for column in cursor.fetchall()]

            # Last.fm columns
            if 'lastfm_match_status' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_match_status TEXT")
            if 'lastfm_last_attempted' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_last_attempted TIMESTAMP")
            if 'lastfm_listeners' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_listeners INTEGER")
            if 'lastfm_playcount' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_playcount INTEGER")
            if 'lastfm_tags' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_tags TEXT")
            if 'lastfm_url' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_url TEXT")

            # Genius columns
            if 'genius_id' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN genius_id TEXT")
            if 'genius_match_status' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN genius_match_status TEXT")
            if 'genius_last_attempted' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN genius_last_attempted TIMESTAMP")
            if 'genius_lyrics' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN genius_lyrics TEXT")
            if 'genius_description' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN genius_description TEXT")
            if 'genius_url' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN genius_url TEXT")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_lastfm_status ON tracks (lastfm_match_status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_genius_id ON tracks (genius_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_genius_status ON tracks (genius_match_status)")

            # One-time reset: clear all Genius matches due to blind-fallback bug in search
            # The old search_artist/search_song returned the first result with no name validation,
            # causing wrong matches. This reset lets the fixed worker re-enrich everything.
            cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_genius_search_fix_applied'")
            if not cursor.fetchone():
                logger.info("Applying one-time Genius search fix: resetting all artist and track matches for re-enrichment")
                cursor.execute("""
                    UPDATE artists SET
                        genius_id = NULL, genius_match_status = NULL, genius_last_attempted = NULL,
                        genius_description = NULL, genius_alt_names = NULL, genius_url = NULL
                    WHERE genius_match_status IS NOT NULL
                """)
                artist_count = cursor.rowcount
                cursor.execute("""
                    UPDATE tracks SET
                        genius_id = NULL, genius_match_status = NULL, genius_last_attempted = NULL,
                        genius_lyrics = NULL, genius_description = NULL, genius_url = NULL
                    WHERE genius_match_status IS NOT NULL
                """)
                track_count = cursor.rowcount
                cursor.execute("CREATE TABLE _genius_search_fix_applied (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
                logger.info(f"Genius search fix applied: reset {artist_count} artists and {track_count} tracks")

        except Exception as e:
            logger.error(f"Error adding Last.fm/Genius enrichment columns: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_tidal_qobuz_enrichment_columns(self, cursor):
        """Add Tidal and Qobuz enrichment tracking columns to artists, albums, tracks"""
        try:
            # --- Artists ---
            cursor.execute("PRAGMA table_info(artists)")
            artists_columns = [column[1] for column in cursor.fetchall()]

            if 'tidal_id' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN tidal_id TEXT")
            if 'tidal_match_status' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN tidal_match_status TEXT")
            if 'tidal_last_attempted' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN tidal_last_attempted TIMESTAMP")
            if 'qobuz_id' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN qobuz_id TEXT")
            if 'qobuz_match_status' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN qobuz_match_status TEXT")
            if 'qobuz_last_attempted' not in artists_columns:
                cursor.execute("ALTER TABLE artists ADD COLUMN qobuz_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_tidal_id ON artists (tidal_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_tidal_status ON artists (tidal_match_status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_qobuz_id ON artists (qobuz_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_qobuz_status ON artists (qobuz_match_status)")

            # --- Albums ---
            cursor.execute("PRAGMA table_info(albums)")
            albums_columns = [column[1] for column in cursor.fetchall()]

            if 'tidal_id' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN tidal_id TEXT")
            if 'tidal_match_status' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN tidal_match_status TEXT")
            if 'tidal_last_attempted' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN tidal_last_attempted TIMESTAMP")
            if 'qobuz_id' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN qobuz_id TEXT")
            if 'qobuz_match_status' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN qobuz_match_status TEXT")
            if 'qobuz_last_attempted' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN qobuz_last_attempted TIMESTAMP")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_tidal_id ON albums (tidal_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_tidal_status ON albums (tidal_match_status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_qobuz_id ON albums (qobuz_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_qobuz_status ON albums (qobuz_match_status)")

            # --- Albums (extra metadata columns) ---
            if 'upc' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN upc TEXT")
            if 'copyright' not in albums_columns:
                cursor.execute("ALTER TABLE albums ADD COLUMN copyright TEXT")

            # --- Tracks ---
            cursor.execute("PRAGMA table_info(tracks)")
            tracks_columns = [column[1] for column in cursor.fetchall()]

            if 'tidal_id' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN tidal_id TEXT")
            if 'tidal_match_status' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN tidal_match_status TEXT")
            if 'tidal_last_attempted' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN tidal_last_attempted TIMESTAMP")
            if 'qobuz_id' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN qobuz_id TEXT")
            if 'qobuz_match_status' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN qobuz_match_status TEXT")
            if 'qobuz_last_attempted' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN qobuz_last_attempted TIMESTAMP")
            if 'isrc' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN isrc TEXT")
            if 'copyright' not in tracks_columns:
                cursor.execute("ALTER TABLE tracks ADD COLUMN copyright TEXT")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_tidal_id ON tracks (tidal_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_tidal_status ON tracks (tidal_match_status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_qobuz_id ON tracks (qobuz_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_qobuz_status ON tracks (qobuz_match_status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_isrc ON tracks (isrc)")

        except Exception as e:
            logger.error(f"Error adding Tidal/Qobuz enrichment columns: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_retag_tables(self, cursor):
        """Add retag tool tables for tracking processed downloads"""
        try:
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS retag_groups (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    group_type TEXT NOT NULL DEFAULT 'album',
                    artist_name TEXT NOT NULL,
                    album_name TEXT NOT NULL,
                    image_url TEXT,
                    spotify_album_id TEXT,
                    itunes_album_id TEXT,
                    total_tracks INTEGER DEFAULT 1,
                    release_date TEXT,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)

            cursor.execute("""
                CREATE TABLE IF NOT EXISTS retag_tracks (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    group_id INTEGER NOT NULL,
                    track_number INTEGER,
                    disc_number INTEGER DEFAULT 1,
                    title TEXT NOT NULL,
                    file_path TEXT NOT NULL,
                    file_format TEXT,
                    spotify_track_id TEXT,
                    itunes_track_id TEXT,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    FOREIGN KEY (group_id) REFERENCES retag_groups (id) ON DELETE CASCADE
                )
            """)

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_retag_groups_artist ON retag_groups (artist_name)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_retag_tracks_group ON retag_tracks (group_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_retag_tracks_path ON retag_tracks (file_path)")

        except Exception as e:
            logger.error(f"Error adding retag tables: {e}")

    def _add_profile_support(self, cursor):
        """Add multi-profile support: profiles table + profile_id on per-profile tables"""
        try:
            # Check if migration already applied
            cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_migration_v1' LIMIT 1")
            already_migrated = cursor.fetchone() is not None

            # Even if already migrated, ensure profile_id columns exist on all tables
            # (another migration may have rebuilt a table without profile_id)
            tables_needing_profile_id = [
                'watchlist_artists', 'wishlist_tracks', 'similar_artists',
                'discovery_pool', 'discovery_recent_albums', 'discovery_curated_playlists',
                'bubble_snapshots', 'recent_releases'
            ]
            for table in tables_needing_profile_id:
                try:
                    cursor.execute(f"PRAGMA table_info({table})")
                    columns = [col[1] for col in cursor.fetchall()]
                    if 'profile_id' not in columns:
                        cursor.execute(f"ALTER TABLE {table} ADD COLUMN profile_id INTEGER DEFAULT 1")
                        logger.info(f"Repaired missing profile_id column on {table}")
                except Exception as e:
                    logger.debug("Failed to repair profile_id column on %s: %s", table, e)

            if already_migrated:
                return  # Rest of migration already done

            logger.info("Adding multi-profile support...")

            # 1. Create profiles table
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS profiles (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    name TEXT NOT NULL UNIQUE,
                    avatar_color TEXT DEFAULT '#6366f1',
                    pin_hash TEXT,
                    is_admin INTEGER NOT NULL DEFAULT 0,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)

            # 2. Insert default admin profile
            cursor.execute("""
                INSERT OR IGNORE INTO profiles (id, name, is_admin)
                VALUES (1, 'Admin', 1)
            """)

            # 3. profile_id columns already ensured above (before early-return guard)

            # 4. Rebuild watchlist_artists to change UNIQUE constraint
            #    Old: UNIQUE(spotify_artist_id)
            #    New: UNIQUE(profile_id, spotify_artist_id)
            try:
                cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='watchlist_artists'")
                create_sql = cursor.fetchone()
                if create_sql and 'UNIQUE(profile_id' not in create_sql[0]:
                    # Get current columns for the table
                    cursor.execute("PRAGMA table_info(watchlist_artists)")
                    cols_info = cursor.fetchall()
                    col_names = [c[1] for c in cols_info]

                    # Drop leftover temp table from any previous failed migration
                    cursor.execute("DROP TABLE IF EXISTS watchlist_artists_new")

                    cursor.execute("""
                        CREATE TABLE watchlist_artists_new (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            spotify_artist_id TEXT,
                            artist_name TEXT NOT NULL,
                            date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            last_scan_timestamp TIMESTAMP,
                            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            image_url TEXT,
                            include_albums INTEGER DEFAULT 1,
                            include_eps INTEGER DEFAULT 1,
                            include_singles INTEGER DEFAULT 1,
                            include_live INTEGER DEFAULT 0,
                            include_remixes INTEGER DEFAULT 0,
                            include_acoustic INTEGER DEFAULT 0,
                            include_compilations INTEGER DEFAULT 0,
                            include_instrumentals INTEGER DEFAULT 0,
                            lookback_days INTEGER DEFAULT NULL,
                            itunes_artist_id TEXT,
                            deezer_artist_id TEXT,
                            discogs_artist_id TEXT,
                            musicbrainz_artist_id TEXT,
                            profile_id INTEGER DEFAULT 1,
                            UNIQUE(profile_id, spotify_artist_id),
                            UNIQUE(profile_id, itunes_artist_id)
                        )
                    """)

                    # Build column list for INSERT (only columns that exist in both)
                    new_cols = ['id', 'spotify_artist_id', 'artist_name', 'date_added',
                                'last_scan_timestamp', 'created_at', 'updated_at', 'image_url',
                                'include_albums', 'include_eps', 'include_singles', 'include_live',
                                'include_remixes', 'include_acoustic', 'include_compilations',
                                'include_instrumentals', 'lookback_days',
                                'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id',
                                'musicbrainz_artist_id', 'profile_id']
                    shared_cols = [c for c in new_cols if c in col_names]
                    cols_str = ', '.join(shared_cols)

                    cursor.execute(f"INSERT INTO watchlist_artists_new ({cols_str}) SELECT {cols_str} FROM watchlist_artists")
                    cursor.execute("DROP TABLE watchlist_artists")
                    cursor.execute("ALTER TABLE watchlist_artists_new RENAME TO watchlist_artists")
                    cursor.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_spotify_id ON watchlist_artists (spotify_artist_id)")
                    cursor.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_profile ON watchlist_artists (profile_id)")
                    logger.info("Rebuilt watchlist_artists with profile-scoped UNIQUE constraints")
            except Exception as e:
                logger.error(f"Error rebuilding watchlist_artists for profiles: {e}")

            # 5. Rebuild wishlist_tracks for profile-scoped uniqueness
            try:
                cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='wishlist_tracks'")
                create_sql = cursor.fetchone()
                if create_sql and 'UNIQUE(profile_id' not in create_sql[0]:
                    cursor.execute("DROP TABLE IF EXISTS wishlist_tracks_new")
                    cursor.execute("""
                        CREATE TABLE wishlist_tracks_new (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            spotify_track_id TEXT NOT NULL,
                            spotify_data TEXT NOT NULL,
                            failure_reason TEXT,
                            retry_count INTEGER DEFAULT 0,
                            last_attempted TIMESTAMP,
                            date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            source_type TEXT DEFAULT 'unknown',
                            source_info TEXT,
                            profile_id INTEGER DEFAULT 1,
                            UNIQUE(profile_id, spotify_track_id)
                        )
                    """)

                    cursor.execute("PRAGMA table_info(wishlist_tracks)")
                    old_cols = [c[1] for c in cursor.fetchall()]
                    new_cols = ['id', 'spotify_track_id', 'spotify_data', 'failure_reason',
                                'retry_count', 'last_attempted', 'date_added', 'source_type',
                                'source_info', 'profile_id']
                    shared_cols = [c for c in new_cols if c in old_cols]
                    cols_str = ', '.join(shared_cols)

                    cursor.execute(f"INSERT INTO wishlist_tracks_new ({cols_str}) SELECT {cols_str} FROM wishlist_tracks")
                    cursor.execute("DROP TABLE wishlist_tracks")
                    cursor.execute("ALTER TABLE wishlist_tracks_new RENAME TO wishlist_tracks")
                    cursor.execute("CREATE INDEX IF NOT EXISTS idx_wishlist_spotify_id ON wishlist_tracks (spotify_track_id)")
                    cursor.execute("CREATE INDEX IF NOT EXISTS idx_wishlist_profile ON wishlist_tracks (profile_id)")
                    logger.info("Rebuilt wishlist_tracks with profile-scoped UNIQUE constraints")
            except Exception as e:
                logger.error(f"Error rebuilding wishlist_tracks for profiles: {e}")

            # 6. Rebuild bubble_snapshots for profile-scoped PRIMARY KEY
            try:
                cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='bubble_snapshots'")
                create_sql = cursor.fetchone()
                if create_sql and 'profile_id' in [c[1] for c in (cursor.execute("PRAGMA table_info(bubble_snapshots)").fetchall())]:
                    cursor.execute("DROP TABLE IF EXISTS bubble_snapshots_new")
                    cursor.execute("""
                        CREATE TABLE bubble_snapshots_new (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            type TEXT NOT NULL,
                            data TEXT NOT NULL,
                            timestamp TEXT NOT NULL,
                            snapshot_id TEXT NOT NULL,
                            profile_id INTEGER DEFAULT 1,
                            UNIQUE(profile_id, type)
                        )
                    """)

                    cursor.execute("""
                        INSERT INTO bubble_snapshots_new (type, data, timestamp, snapshot_id, profile_id)
                        SELECT type, data, timestamp, snapshot_id, profile_id FROM bubble_snapshots
                    """)
                    cursor.execute("DROP TABLE bubble_snapshots")
                    cursor.execute("ALTER TABLE bubble_snapshots_new RENAME TO bubble_snapshots")
                    logger.info("Rebuilt bubble_snapshots with profile-scoped UNIQUE constraints")
            except Exception as e:
                logger.error(f"Error rebuilding bubble_snapshots for profiles: {e}")

            # 7. Rebuild discovery_curated_playlists for profile-scoped uniqueness
            try:
                cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='discovery_curated_playlists'")
                create_sql = cursor.fetchone()
                if create_sql and 'UNIQUE(profile_id' not in create_sql[0]:
                    cursor.execute("DROP TABLE IF EXISTS discovery_curated_playlists_new")
                    cursor.execute("""
                        CREATE TABLE discovery_curated_playlists_new (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            playlist_type TEXT NOT NULL,
                            track_ids_json TEXT NOT NULL,
                            curated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            profile_id INTEGER DEFAULT 1,
                            UNIQUE(profile_id, playlist_type)
                        )
                    """)

                    cursor.execute("PRAGMA table_info(discovery_curated_playlists)")
                    old_cols = [c[1] for c in cursor.fetchall()]
                    new_cols = ['id', 'playlist_type', 'track_ids_json', 'curated_date', 'profile_id']
                    shared_cols = [c for c in new_cols if c in old_cols]
                    cols_str = ', '.join(shared_cols)

                    cursor.execute(f"INSERT INTO discovery_curated_playlists_new ({cols_str}) SELECT {cols_str} FROM discovery_curated_playlists")
                    cursor.execute("DROP TABLE discovery_curated_playlists")
                    cursor.execute("ALTER TABLE discovery_curated_playlists_new RENAME TO discovery_curated_playlists")
                    logger.info("Rebuilt discovery_curated_playlists with profile-scoped UNIQUE constraints")
            except Exception as e:
                logger.error(f"Error rebuilding discovery_curated_playlists for profiles: {e}")

            # 8. Add indexes for profile_id on remaining tables
            index_pairs = [
                ('idx_similar_artists_profile', 'similar_artists'),
                ('idx_discovery_pool_profile', 'discovery_pool'),
                ('idx_discovery_recent_albums_profile', 'discovery_recent_albums'),
                ('idx_recent_releases_profile', 'recent_releases'),
            ]
            for idx_name, table in index_pairs:
                try:
                    cursor.execute(f"CREATE INDEX IF NOT EXISTS {idx_name} ON {table} (profile_id)")
                except Exception as e:
                    logger.debug("Failed to create index %s on %s: %s", idx_name, table, e)

            # Set migration marker
            cursor.execute("""
                INSERT OR REPLACE INTO metadata (key, value, updated_at)
                VALUES ('profiles_migration_v1', 'true', CURRENT_TIMESTAMP)
            """)

            logger.info("Multi-profile support migration completed successfully")

        except Exception as e:
            logger.error(f"Error adding profile support: {e}")
            # Don't raise - this is a migration, database can still function

    def _add_profile_support_v2(self, cursor):
        """Fix missing profile-scoped UNIQUE constraints on 3 tables (v2 migration)"""
        try:
            cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_migration_v2' LIMIT 1")
            if cursor.fetchone():
                return  # Already migrated

            logger.info("Applying profile support v2 migration...")

            # Rebuild discovery_pool: UNIQUE(profile_id, spotify_track_id, itunes_track_id, source)
            try:
                cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='discovery_pool'")
                create_sql = cursor.fetchone()
                if create_sql and 'UNIQUE(profile_id' not in create_sql[0]:
                    cursor.execute("PRAGMA table_info(discovery_pool)")
                    old_cols = [c[1] for c in cursor.fetchall()]

                    cursor.execute("DROP TABLE IF EXISTS discovery_pool_new")
                    cursor.execute("""
                        CREATE TABLE discovery_pool_new (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            spotify_track_id TEXT,
                            spotify_album_id TEXT,
                            spotify_artist_id TEXT,
                            itunes_track_id TEXT,
                            itunes_album_id TEXT,
                            itunes_artist_id TEXT,
                            deezer_track_id TEXT,
                            deezer_album_id TEXT,
                            deezer_artist_id TEXT,
                            source TEXT NOT NULL DEFAULT 'spotify',
                            track_name TEXT NOT NULL,
                            artist_name TEXT NOT NULL,
                            album_name TEXT NOT NULL,
                            album_cover_url TEXT,
                            duration_ms INTEGER,
                            popularity INTEGER DEFAULT 0,
                            release_date TEXT,
                            is_new_release BOOLEAN DEFAULT 0,
                            track_data_json TEXT NOT NULL,
                            artist_genres TEXT,
                            added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            profile_id INTEGER DEFAULT 1,
                            UNIQUE(profile_id, spotify_track_id, itunes_track_id, source)
                        )
                    """)

                    new_cols = ['id', 'spotify_track_id', 'spotify_album_id', 'spotify_artist_id',
                                'itunes_track_id', 'itunes_album_id', 'itunes_artist_id',
                                'deezer_track_id', 'deezer_album_id', 'deezer_artist_id',
                                'source', 'track_name', 'artist_name', 'album_name', 'album_cover_url',
                                'duration_ms', 'popularity', 'release_date', 'is_new_release',
                                'track_data_json', 'artist_genres', 'added_date', 'profile_id']
                    shared_cols = [c for c in new_cols if c in old_cols]
                    cols_str = ', '.join(shared_cols)

                    cursor.execute(f"INSERT INTO discovery_pool_new ({cols_str}) SELECT {cols_str} FROM discovery_pool")
                    cursor.execute("DROP TABLE discovery_pool")
                    cursor.execute("ALTER TABLE discovery_pool_new RENAME TO discovery_pool")
                    cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_profile ON discovery_pool (profile_id)")
                    logger.info("Rebuilt discovery_pool with profile-scoped UNIQUE constraint")
            except Exception as e:
                logger.error(f"Error rebuilding discovery_pool for profiles v2: {e}")

            # Rebuild discovery_recent_albums: UNIQUE(profile_id, album_spotify_id, album_itunes_id, source)
            try:
                cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='discovery_recent_albums'")
                create_sql = cursor.fetchone()
                if create_sql and 'UNIQUE(profile_id' not in create_sql[0]:
                    cursor.execute("PRAGMA table_info(discovery_recent_albums)")
                    old_cols = [c[1] for c in cursor.fetchall()]

                    cursor.execute("DROP TABLE IF EXISTS discovery_recent_albums_new")
                    cursor.execute("""
                        CREATE TABLE discovery_recent_albums_new (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            album_spotify_id TEXT,
                            album_itunes_id TEXT,
                            album_deezer_id TEXT,
                            artist_spotify_id TEXT,
                            artist_itunes_id TEXT,
                            artist_deezer_id TEXT,
                            source TEXT NOT NULL DEFAULT 'spotify',
                            album_name TEXT NOT NULL,
                            artist_name TEXT NOT NULL,
                            album_cover_url TEXT,
                            release_date TEXT NOT NULL,
                            album_type TEXT DEFAULT 'album',
                            cached_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            profile_id INTEGER DEFAULT 1,
                            UNIQUE(profile_id, album_spotify_id, album_itunes_id, album_deezer_id, source)
                        )
                    """)

                    new_cols = ['id', 'album_spotify_id', 'album_itunes_id', 'album_deezer_id',
                                'artist_spotify_id', 'artist_itunes_id', 'artist_deezer_id',
                                'source', 'album_name', 'artist_name',
                                'album_cover_url', 'release_date', 'album_type', 'cached_date', 'profile_id']
                    shared_cols = [c for c in new_cols if c in old_cols]
                    cols_str = ', '.join(shared_cols)

                    cursor.execute(f"INSERT INTO discovery_recent_albums_new ({cols_str}) SELECT {cols_str} FROM discovery_recent_albums")
                    cursor.execute("DROP TABLE discovery_recent_albums")
                    cursor.execute("ALTER TABLE discovery_recent_albums_new RENAME TO discovery_recent_albums")
                    cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_profile ON discovery_recent_albums (profile_id)")
                    logger.info("Rebuilt discovery_recent_albums with profile-scoped UNIQUE constraint")
            except Exception as e:
                logger.error(f"Error rebuilding discovery_recent_albums for profiles v2: {e}")

            # Rebuild recent_releases: UNIQUE(profile_id, watchlist_artist_id, album_spotify_id, album_itunes_id)
            try:
                cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='recent_releases'")
                create_sql = cursor.fetchone()
                if create_sql and 'UNIQUE(profile_id' not in create_sql[0]:
                    cursor.execute("PRAGMA table_info(recent_releases)")
                    old_cols = [c[1] for c in cursor.fetchall()]

                    cursor.execute("DROP TABLE IF EXISTS recent_releases_new")
                    cursor.execute("""
                        CREATE TABLE recent_releases_new (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            watchlist_artist_id INTEGER NOT NULL,
                            album_spotify_id TEXT,
                            album_itunes_id TEXT,
                            album_deezer_id TEXT,
                            source TEXT NOT NULL DEFAULT 'spotify',
                            album_name TEXT NOT NULL,
                            release_date TEXT NOT NULL,
                            album_cover_url TEXT,
                            track_count INTEGER DEFAULT 0,
                            added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            profile_id INTEGER DEFAULT 1,
                            UNIQUE(profile_id, watchlist_artist_id, album_spotify_id, album_itunes_id)
                        )
                    """)

                    new_cols = ['id', 'watchlist_artist_id', 'album_spotify_id', 'album_itunes_id',
                                'album_deezer_id', 'source', 'album_name', 'release_date',
                                'album_cover_url', 'track_count', 'added_date', 'profile_id']
                    shared_cols = [c for c in new_cols if c in old_cols]
                    cols_str = ', '.join(shared_cols)

                    cursor.execute(f"INSERT INTO recent_releases_new ({cols_str}) SELECT {cols_str} FROM recent_releases")
                    cursor.execute("DROP TABLE recent_releases")
                    cursor.execute("ALTER TABLE recent_releases_new RENAME TO recent_releases")
                    cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_profile ON recent_releases (profile_id)")
                    logger.info("Rebuilt recent_releases with profile-scoped UNIQUE constraint")
            except Exception as e:
                logger.error(f"Error rebuilding recent_releases for profiles v2: {e}")

            # Set migration marker
            cursor.execute("""
                INSERT OR REPLACE INTO metadata (key, value, updated_at)
                VALUES ('profiles_migration_v2', 'true', CURRENT_TIMESTAMP)
            """)

            logger.info("Profile support v2 migration completed successfully")

        except Exception as e:
            logger.error(f"Error in profile support v2 migration: {e}")

    def _add_profile_support_v3(self, cursor):
        """Fix similar_artists UNIQUE constraint and make discovery_pool_metadata per-profile (v3 migration)"""
        try:
            cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_migration_v3' LIMIT 1")
            already_migrated = cursor.fetchone() is not None

            # Always check if similar_artists actually has profile_id column
            # (an older bug could strip it even after v3 migration ran)
            cursor.execute("PRAGMA table_info(similar_artists)")
            sa_cols = [c[1] for c in cursor.fetchall()]
            needs_repair = 'profile_id' not in sa_cols

            if already_migrated and not needs_repair:
                return  # Already migrated and table is intact

            if needs_repair:
                logger.info("Repairing similar_artists table — profile_id column missing, rebuilding...")
            else:
                logger.info("Applying profile support v3 migration...")

            # Rebuild similar_artists: UNIQUE(profile_id, source_artist_id, similar_artist_name)
            try:
                cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='similar_artists'")
                create_sql = cursor.fetchone()
                if create_sql and ('UNIQUE(profile_id' not in create_sql[0] or needs_repair):
                    cursor.execute("PRAGMA table_info(similar_artists)")
                    old_cols = [c[1] for c in cursor.fetchall()]

                    cursor.execute("DROP TABLE IF EXISTS similar_artists_new")
                    cursor.execute("""
                        CREATE TABLE similar_artists_new (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            source_artist_id TEXT NOT NULL,
                            similar_artist_spotify_id TEXT,
                            similar_artist_itunes_id TEXT,
                            similar_artist_deezer_id TEXT,
                            similar_artist_musicbrainz_id TEXT,
                            similar_artist_name TEXT NOT NULL,
                            similarity_rank INTEGER DEFAULT 1,
                            occurrence_count INTEGER DEFAULT 1,
                            last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                            image_url TEXT,
                            genres TEXT,
                            popularity INTEGER DEFAULT 0,
                            metadata_updated_at TIMESTAMP,
                            last_featured TIMESTAMP,
                            profile_id INTEGER DEFAULT 1,
                            UNIQUE(profile_id, source_artist_id, similar_artist_name)
                        )
                    """)

                    new_cols = ['id', 'source_artist_id', 'similar_artist_spotify_id',
                                'similar_artist_itunes_id', 'similar_artist_deezer_id',
                                'similar_artist_musicbrainz_id', 'similar_artist_name',
                                'similarity_rank', 'occurrence_count',
                                'last_updated', 'image_url', 'genres', 'popularity',
                                'metadata_updated_at', 'last_featured', 'profile_id']
                    shared_cols = [c for c in new_cols if c in old_cols]
                    cols_str = ', '.join(shared_cols)

                    cursor.execute(f"INSERT INTO similar_artists_new ({cols_str}) SELECT {cols_str} FROM similar_artists")
                    cursor.execute("DROP TABLE similar_artists")
                    cursor.execute("ALTER TABLE similar_artists_new RENAME TO similar_artists")
                    cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_profile ON similar_artists (profile_id)")
                    logger.info("Rebuilt similar_artists with profile-scoped UNIQUE constraint")
            except Exception as e:
                logger.error(f"Error rebuilding similar_artists for profiles v3: {e}")

            # Make discovery_pool_metadata per-profile: change CHECK(id=1) to use profile_id as key
            try:
                cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='discovery_pool_metadata'")
                create_sql = cursor.fetchone()
                if create_sql and 'profile_id' not in create_sql[0]:
                    cursor.execute("DROP TABLE IF EXISTS discovery_pool_metadata_new")
                    cursor.execute("""
                        CREATE TABLE discovery_pool_metadata_new (
                            id INTEGER PRIMARY KEY AUTOINCREMENT,
                            profile_id INTEGER NOT NULL DEFAULT 1 UNIQUE,
                            last_populated_timestamp TIMESTAMP NOT NULL,
                            track_count INTEGER DEFAULT 0,
                            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                        )
                    """)
                    # Migrate existing row (profile 1)
                    cursor.execute("""
                        INSERT OR IGNORE INTO discovery_pool_metadata_new
                        (profile_id, last_populated_timestamp, track_count, updated_at)
                        SELECT 1, last_populated_timestamp, track_count, updated_at
                        FROM discovery_pool_metadata WHERE id = 1
                    """)
                    cursor.execute("DROP TABLE discovery_pool_metadata")
                    cursor.execute("ALTER TABLE discovery_pool_metadata_new RENAME TO discovery_pool_metadata")
                    logger.info("Rebuilt discovery_pool_metadata with per-profile support")
            except Exception as e:
                logger.error(f"Error rebuilding discovery_pool_metadata for profiles v3: {e}")

            # Set migration marker
            cursor.execute("""
                INSERT OR REPLACE INTO metadata (key, value, updated_at)
                VALUES ('profiles_migration_v3', 'true', CURRENT_TIMESTAMP)
            """)

            logger.info("Profile support v3 migration completed successfully")

        except Exception as e:
            logger.error(f"Error in profile support v3 migration: {e}")

    def _add_profile_support_v4(self, cursor):
        """Add avatar_url column to profiles table (v4 migration)"""
        try:
            cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_migration_v4' LIMIT 1")
            if cursor.fetchone():
                return  # Already migrated

            logger.info("Applying profile support v4 migration...")

            # Add avatar_url column
            try:
                cursor.execute("ALTER TABLE profiles ADD COLUMN avatar_url TEXT DEFAULT NULL")
            except sqlite3.OperationalError:
                pass  # Column already exists

            cursor.execute("""
                INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_migration_v4', '1')
            """)

            logger.info("Profile support v4 migration completed successfully")

        except Exception as e:
            logger.error(f"Error in profile support v4 migration: {e}")

    def _add_profile_settings(self, cursor):
        """Add home_page, allowed_pages, can_download columns to profiles table"""
        try:
            cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_migration_settings' LIMIT 1")
            if cursor.fetchone():
                return  # Already migrated

            logger.info("Applying profile settings migration...")

            for col_sql in [
                "ALTER TABLE profiles ADD COLUMN home_page TEXT DEFAULT NULL",
                "ALTER TABLE profiles ADD COLUMN allowed_pages TEXT DEFAULT NULL",
                "ALTER TABLE profiles ADD COLUMN can_download INTEGER DEFAULT 1",
            ]:
                try:
                    cursor.execute(col_sql)
                except sqlite3.OperationalError:
                    pass  # Column already exists

            cursor.execute("""
                INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_migration_settings', '1')
            """)

            logger.info("Profile settings migration completed successfully")

        except Exception as e:
            logger.error(f"Error in profile settings migration: {e}")

    def _add_profile_listenbrainz_support(self, cursor):
        """Add per-profile ListenBrainz credentials and scope playlist cache by profile"""
        try:
            cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_listenbrainz_v1' LIMIT 1")
            if cursor.fetchone():
                return  # Already migrated

            logger.info("Applying per-profile ListenBrainz migration...")

            # Per-profile LB credentials on profiles table
            for col_sql in [
                "ALTER TABLE profiles ADD COLUMN listenbrainz_token TEXT DEFAULT NULL",
                "ALTER TABLE profiles ADD COLUMN listenbrainz_base_url TEXT DEFAULT NULL",
                "ALTER TABLE profiles ADD COLUMN listenbrainz_username TEXT DEFAULT NULL",
            ]:
                try:
                    cursor.execute(col_sql)
                except sqlite3.OperationalError:
                    pass  # Column already exists

            # Recreate listenbrainz_playlists with profile_id and compound unique constraint
            # (SQLite can't ALTER constraints, so we must recreate the table)
            cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='listenbrainz_playlists'")
            if cursor.fetchone():
                cursor.execute("""
                    CREATE TABLE IF NOT EXISTS listenbrainz_playlists_new (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        playlist_mbid TEXT NOT NULL,
                        title TEXT NOT NULL,
                        creator TEXT,
                        playlist_type TEXT NOT NULL,
                        track_count INTEGER DEFAULT 0,
                        annotation_data TEXT,
                        profile_id INTEGER DEFAULT 1,
                        last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                        cached_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                        UNIQUE(playlist_mbid, profile_id)
                    )
                """)
                cursor.execute("""
                    INSERT OR IGNORE INTO listenbrainz_playlists_new
                    (id, playlist_mbid, title, creator, playlist_type, track_count, annotation_data, profile_id, last_updated, cached_date)
                    SELECT id, playlist_mbid, title, creator, playlist_type, track_count, annotation_data, 1, last_updated, cached_date
                    FROM listenbrainz_playlists
                """)
                cursor.execute("DROP TABLE listenbrainz_playlists")
                cursor.execute("ALTER TABLE listenbrainz_playlists_new RENAME TO listenbrainz_playlists")

                # Clean up playlists that lost their tracks during table recreation
                # (track playlist_id foreign keys may reference stale IDs).
                # This forces a fresh re-fetch from ListenBrainz on next page load.
                cursor.execute("""
                    DELETE FROM listenbrainz_playlists
                    WHERE id NOT IN (SELECT DISTINCT playlist_id FROM listenbrainz_tracks)
                """)
                cleaned = cursor.rowcount
                if cleaned:
                    logger.info(f"Cleaned up {cleaned} stale playlists (will re-fetch from ListenBrainz)")

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_lb_playlists_profile ON listenbrainz_playlists (profile_id)")

            cursor.execute("""
                INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_listenbrainz_v1', '1')
            """)

            logger.info("Per-profile ListenBrainz migration completed successfully")

        except Exception as e:
            logger.error(f"Error in per-profile ListenBrainz migration: {e}")

    def set_profile_listenbrainz(self, profile_id: int, token: str, base_url: str = '', username: str = '') -> bool:
        """Save encrypted ListenBrainz credentials for a profile"""
        try:
            from config.settings import config_manager
            encrypted_token = config_manager._encrypt_value(token) if token else None
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    UPDATE profiles
                    SET listenbrainz_token = ?, listenbrainz_base_url = ?, listenbrainz_username = ?,
                        updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                """, (encrypted_token, base_url or None, username or None, profile_id))
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error setting ListenBrainz credentials for profile {profile_id}: {e}")
            return False

    def get_profile_listenbrainz(self, profile_id: int) -> Dict[str, Any]:
        """Get decrypted ListenBrainz credentials for a profile"""
        try:
            from config.settings import config_manager
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT listenbrainz_token, listenbrainz_base_url, listenbrainz_username
                    FROM profiles WHERE id = ?
                """, (profile_id,))
                row = cursor.fetchone()
                if not row:
                    return {'token': None, 'base_url': None, 'username': None}
                token_raw = row[0]
                token = config_manager._decrypt_value(token_raw) if token_raw else None
                return {
                    'token': token,
                    'base_url': row[1] or '',
                    'username': row[2] or '',
                }
        except Exception as e:
            logger.error(f"Error getting ListenBrainz credentials for profile {profile_id}: {e}")
            return {'token': None, 'base_url': None, 'username': None}

    def clear_profile_listenbrainz(self, profile_id: int) -> bool:
        """Clear ListenBrainz credentials for a profile"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    UPDATE profiles
                    SET listenbrainz_token = NULL, listenbrainz_base_url = NULL, listenbrainz_username = NULL,
                        updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                """, (profile_id,))
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error clearing ListenBrainz credentials for profile {profile_id}: {e}")
            return False

    def get_profiles_with_listenbrainz(self) -> List[Dict[str, Any]]:
        """Get all profiles that have ListenBrainz tokens configured"""
        try:
            from config.settings import config_manager
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT id, listenbrainz_token, listenbrainz_base_url
                    FROM profiles WHERE listenbrainz_token IS NOT NULL
                """)
                results = []
                for row in cursor.fetchall():
                    token = config_manager._decrypt_value(row[1]) if row[1] else None
                    if token:
                        results.append({
                            'id': row[0],
                            'token': token,
                            'base_url': row[2] or '',
                        })
                return results
        except Exception as e:
            logger.error(f"Error getting profiles with ListenBrainz tokens: {e}")
            return []

    # ── Per-profile service credentials (Spotify, Tidal, server library) ──

    def _add_profile_service_credentials(self, cursor):
        """Add per-profile Spotify, Tidal, and media server library columns to profiles table."""
        try:
            cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_services_v1' LIMIT 1")
            if cursor.fetchone():
                return  # Already migrated

            logger.info("Applying per-profile service credentials migration...")

            columns = [
                # Spotify per-profile
                "ALTER TABLE profiles ADD COLUMN spotify_client_id TEXT DEFAULT NULL",
                "ALTER TABLE profiles ADD COLUMN spotify_client_secret TEXT DEFAULT NULL",
                "ALTER TABLE profiles ADD COLUMN spotify_redirect_uri TEXT DEFAULT NULL",
                "ALTER TABLE profiles ADD COLUMN spotify_access_token TEXT DEFAULT NULL",
                "ALTER TABLE profiles ADD COLUMN spotify_refresh_token TEXT DEFAULT NULL",
                # Tidal per-profile
                "ALTER TABLE profiles ADD COLUMN tidal_access_token TEXT DEFAULT NULL",
                "ALTER TABLE profiles ADD COLUMN tidal_refresh_token TEXT DEFAULT NULL",
                # Media server library selection per-profile
                "ALTER TABLE profiles ADD COLUMN plex_library_id TEXT DEFAULT NULL",
                "ALTER TABLE profiles ADD COLUMN jellyfin_user_id TEXT DEFAULT NULL",
                "ALTER TABLE profiles ADD COLUMN jellyfin_library_id TEXT DEFAULT NULL",
                "ALTER TABLE profiles ADD COLUMN navidrome_library_id TEXT DEFAULT NULL",
            ]

            for sql in columns:
                try:
                    cursor.execute(sql)
                except sqlite3.OperationalError:
                    pass  # Column already exists

            cursor.execute("""
                INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_services_v1', '1')
            """)

            logger.info("Per-profile service credentials migration completed")

        except Exception as e:
            logger.error(f"Error in per-profile service credentials migration: {e}")

    def _add_soul_id_columns(self, cursor):
        """Add soul_id columns to artists, albums, and tracks tables."""
        try:
            # Artists: soul_id
            cursor.execute("PRAGMA table_info(artists)")
            artist_cols = [c[1] for c in cursor.fetchall()]
            if 'soul_id' not in artist_cols:
                cursor.execute("ALTER TABLE artists ADD COLUMN soul_id TEXT DEFAULT NULL")
                logger.info("Added soul_id column to artists table")

            # Albums: soul_id
            cursor.execute("PRAGMA table_info(albums)")
            album_cols = [c[1] for c in cursor.fetchall()]
            if 'soul_id' not in album_cols:
                cursor.execute("ALTER TABLE albums ADD COLUMN soul_id TEXT DEFAULT NULL")
                logger.info("Added soul_id column to albums table")

            # Albums: api_track_count — cached expected track count from the
            # metadata provider, separate from track_count which is the
            # OBSERVED count written by server syncs (Plex leafCount,
            # SoulSync standalone len(tracks)). Without a separate column,
            # the Album Completeness job can't tell apart "you have all the
            # tracks" from "Plex says this album has N tracks and you have
            # N tracks" — the latter looks complete but might be missing
            # material the metadata source knows about. NULL = not yet
            # looked up; the repair job fills it as it runs.
            if 'api_track_count' not in album_cols:
                cursor.execute("ALTER TABLE albums ADD COLUMN api_track_count INTEGER DEFAULT NULL")
                logger.info("Added api_track_count column to albums table")

            # Tracks: soul_id (song-level) + album_soul_id (release-specific)
            cursor.execute("PRAGMA table_info(tracks)")
            track_cols = [c[1] for c in cursor.fetchall()]
            if 'soul_id' not in track_cols:
                cursor.execute("ALTER TABLE tracks ADD COLUMN soul_id TEXT DEFAULT NULL")
                logger.info("Added soul_id column to tracks table")
            if 'album_soul_id' not in track_cols:
                cursor.execute("ALTER TABLE tracks ADD COLUMN album_soul_id TEXT DEFAULT NULL")
                logger.info("Added album_soul_id column to tracks table")

            # Indexes for lookups
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_soul_id ON artists (soul_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_soul_id ON albums (soul_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_soul_id ON tracks (soul_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_album_soul_id ON tracks (album_soul_id)")

            # v2.1 migration: regenerate artist soul_ids with new canonical ID algorithm
            # (was name+debut_year, now name+max(deezer_id,itunes_id) via track-verified lookup)
            cursor.execute("SELECT value FROM metadata WHERE key = 'soulid_v2_migration'")
            if not cursor.fetchone():
                cursor.execute("UPDATE artists SET soul_id = NULL")
                cleared = cursor.rowcount
                cursor.execute("INSERT OR REPLACE INTO metadata (key, value) VALUES ('soulid_v2_migration', '1')")
                if cleared > 0:
                    logger.info(f"SoulID v2 migration: cleared {cleared} artist soul_ids for regeneration")

        except Exception as e:
            logger.error(f"Error adding soul_id columns: {e}")

    def _add_listening_history_table(self, cursor):
        """Create listening_history table and add play_count/last_played to tracks."""
        try:
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS listening_history (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    track_id TEXT,
                    title TEXT NOT NULL,
                    artist TEXT,
                    album TEXT,
                    played_at TIMESTAMP NOT NULL,
                    duration_ms INTEGER DEFAULT 0,
                    server_source TEXT,
                    db_track_id INTEGER,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_listening_played_at ON listening_history (played_at)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_listening_artist ON listening_history (artist)")
            cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_listening_dedup ON listening_history (track_id, played_at, server_source)")

            # Add play_count and last_played to tracks table
            cursor.execute("PRAGMA table_info(tracks)")
            track_cols = [c[1] for c in cursor.fetchall()]
            if 'play_count' not in track_cols:
                cursor.execute("ALTER TABLE tracks ADD COLUMN play_count INTEGER DEFAULT 0")
                logger.info("Added play_count column to tracks table")
            if 'last_played' not in track_cols:
                cursor.execute("ALTER TABLE tracks ADD COLUMN last_played TIMESTAMP")
                logger.info("Added last_played column to tracks table")

            # Add scrobble tracking columns to listening_history
            cursor.execute("PRAGMA table_info(listening_history)")
            lh_cols = [c[1] for c in cursor.fetchall()]
            if 'scrobbled_lastfm' not in lh_cols:
                cursor.execute("ALTER TABLE listening_history ADD COLUMN scrobbled_lastfm INTEGER DEFAULT 0")
                logger.info("Added scrobbled_lastfm column to listening_history")
            if 'scrobbled_listenbrainz' not in lh_cols:
                cursor.execute("ALTER TABLE listening_history ADD COLUMN scrobbled_listenbrainz INTEGER DEFAULT 0")
                logger.info("Added scrobbled_listenbrainz column to listening_history")

        except Exception as e:
            logger.error(f"Error creating listening_history table: {e}")

    def insert_listening_events(self, events):
        """Bulk insert listening events, skipping duplicates."""
        if not events:
            return 0
        conn = None
        inserted = 0
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            for event in events:
                try:
                    cursor.execute("""
                        INSERT OR IGNORE INTO listening_history
                            (track_id, title, artist, album, played_at, duration_ms, server_source, db_track_id)
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                    """, (
                        event.get('track_id'),
                        event.get('title', ''),
                        event.get('artist', ''),
                        event.get('album', ''),
                        event.get('played_at'),
                        event.get('duration_ms', 0),
                        event.get('server_source', ''),
                        event.get('db_track_id'),
                    ))
                    if cursor.rowcount > 0:
                        inserted += 1
                except Exception as e:
                    logger.debug("Failed to insert listening event: %s", e)
            conn.commit()
            return inserted
        except Exception as e:
            logger.error(f"Error inserting listening events: {e}")
            return 0
        finally:
            if conn:
                conn.close()

    def update_track_play_counts(self, counts):
        """Update play_count and last_played on the tracks table.

        Args:
            counts: list of dicts with {db_track_id, play_count, last_played}
        """
        if not counts:
            return
        conn = None
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            for item in counts:
                cursor.execute("""
                    UPDATE tracks SET play_count = ?, last_played = ?
                    WHERE id = ?
                """, (item.get('play_count', 0), item.get('last_played'), item.get('db_track_id')))
            conn.commit()
        except Exception as e:
            logger.error(f"Error updating track play counts: {e}")
        finally:
            if conn:
                conn.close()

    def get_listening_stats(self, time_range='all'):
        """Get aggregate listening stats for a time range.

        Args:
            time_range: '7d', '30d', '12m', or 'all'

        Returns:
            Dict with total_plays, total_time_ms, unique_artists, unique_albums, unique_tracks
        """
        conn = None
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            where = self._listening_time_filter(time_range)

            cursor.execute(f"""
                SELECT
                    COUNT(*) as total_plays,
                    COALESCE(SUM(duration_ms), 0) as total_time_ms,
                    COUNT(DISTINCT artist) as unique_artists,
                    COUNT(DISTINCT album) as unique_albums,
                    COUNT(DISTINCT title || '|||' || COALESCE(artist, '')) as unique_tracks
                FROM listening_history
                {where}
            """)
            row = cursor.fetchone()
            return {
                'total_plays': row[0] or 0,
                'total_time_ms': row[1] or 0,
                'unique_artists': row[2] or 0,
                'unique_albums': row[3] or 0,
                'unique_tracks': row[4] or 0,
            }
        except Exception as e:
            logger.error(f"Error getting listening stats: {e}")
            return {'total_plays': 0, 'total_time_ms': 0, 'unique_artists': 0, 'unique_albums': 0, 'unique_tracks': 0}
        finally:
            if conn:
                conn.close()

    def get_top_artists(self, time_range='all', limit=10):
        """Get top artists by play count."""
        conn = None
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            where = self._listening_time_filter(time_range)

            cursor.execute(f"""
                SELECT artist, COUNT(*) as play_count
                FROM listening_history
                {where}
                AND artist IS NOT NULL AND artist != ''
                GROUP BY LOWER(artist)
                ORDER BY play_count DESC
                LIMIT ?
            """, (limit,))
            return [{'name': row[0], 'play_count': row[1]} for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting top artists: {e}")
            return []
        finally:
            if conn:
                conn.close()

    def get_top_albums(self, time_range='all', limit=10):
        """Get top albums by play count."""
        conn = None
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            where = self._listening_time_filter(time_range)

            cursor.execute(f"""
                SELECT album, artist, COUNT(*) as play_count
                FROM listening_history
                {where}
                AND album IS NOT NULL AND album != ''
                GROUP BY LOWER(album), LOWER(artist)
                ORDER BY play_count DESC
                LIMIT ?
            """, (limit,))
            return [{'name': row[0], 'artist': row[1], 'play_count': row[2]} for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting top albums: {e}")
            return []
        finally:
            if conn:
                conn.close()

    def get_top_tracks(self, time_range='all', limit=10):
        """Get top tracks by play count."""
        conn = None
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            where = self._listening_time_filter(time_range)

            cursor.execute(f"""
                SELECT title, artist, album, COUNT(*) as play_count
                FROM listening_history
                {where}
                AND title IS NOT NULL AND title != ''
                GROUP BY LOWER(title), LOWER(artist)
                ORDER BY play_count DESC
                LIMIT ?
            """, (limit,))
            return [{'name': row[0], 'artist': row[1], 'album': row[2], 'play_count': row[3]} for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting top tracks: {e}")
            return []
        finally:
            if conn:
                conn.close()

    def get_listening_timeline(self, time_range='30d', granularity='day'):
        """Get play count per time period for chart rendering."""
        conn = None
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            where = self._listening_time_filter(time_range)

            if granularity == 'month':
                date_fmt = '%Y-%m'
            elif granularity == 'week':
                date_fmt = '%Y-W%W'
            else:
                date_fmt = '%Y-%m-%d'

            cursor.execute(f"""
                SELECT strftime('{date_fmt}', played_at) as period, COUNT(*) as plays
                FROM listening_history
                {where}
                GROUP BY period
                ORDER BY period ASC
            """)
            return [{'date': row[0], 'plays': row[1]} for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting listening timeline: {e}")
            return []
        finally:
            if conn:
                conn.close()

    def get_genre_breakdown(self, time_range='all'):
        """Get genre distribution by play count (joins listening_history to tracks/artists)."""
        conn = None
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            where = self._listening_time_filter(time_range, alias='lh')

            cursor.execute(f"""
                SELECT a.genres, COUNT(*) as play_count
                FROM listening_history lh
                JOIN tracks t ON t.id = lh.db_track_id
                JOIN artists a ON a.id = t.artist_id
                {where}
                AND a.genres IS NOT NULL AND a.genres != ''
                GROUP BY a.genres
                ORDER BY play_count DESC
                LIMIT 50
            """)
            # Parse genre JSON and aggregate
            genre_counts = {}
            for row in cursor.fetchall():
                genres_str = row[0]
                count = row[1]
                try:
                    import json
                    genres = json.loads(genres_str)
                    if isinstance(genres, list):
                        for g in genres:
                            genre_counts[g] = genre_counts.get(g, 0) + count
                    else:
                        genre_counts[str(genres)] = genre_counts.get(str(genres), 0) + count
                except (ValueError, TypeError):
                    for g in genres_str.split(','):
                        g = g.strip()
                        if g:
                            genre_counts[g] = genre_counts.get(g, 0) + count

            total = sum(genre_counts.values()) or 1
            result = sorted(
                [{'genre': g, 'play_count': c, 'percentage': round(c / total * 100, 1)} for g, c in genre_counts.items()],
                key=lambda x: x['play_count'], reverse=True
            )[:15]
            return result
        except Exception as e:
            logger.error(f"Error getting genre breakdown: {e}")
            return []
        finally:
            if conn:
                conn.close()

    def get_library_health(self):
        """Get library health metrics."""
        conn = None
        try:
            conn = self._get_connection()
            cursor = conn.cursor()

            # Total tracks
            cursor.execute("SELECT COUNT(*) FROM tracks WHERE id IS NOT NULL")
            total_tracks = (cursor.fetchone() or [0])[0]

            # Unplayed
            cursor.execute("SELECT COUNT(*) FROM tracks WHERE (play_count IS NULL OR play_count = 0) AND id IS NOT NULL")
            unplayed = (cursor.fetchone() or [0])[0]

            # Format breakdown
            cursor.execute("""
                SELECT
                    CASE
                        WHEN LOWER(file_path) LIKE '%.flac' THEN 'FLAC'
                        WHEN LOWER(file_path) LIKE '%.mp3' THEN 'MP3'
                        WHEN LOWER(file_path) LIKE '%.opus' THEN 'Opus'
                        WHEN LOWER(file_path) LIKE '%.m4a' THEN 'AAC'
                        WHEN LOWER(file_path) LIKE '%.ogg' THEN 'OGG'
                        WHEN LOWER(file_path) LIKE '%.wav' THEN 'WAV'
                        ELSE 'Other'
                    END as format,
                    COUNT(*) as count
                FROM tracks
                WHERE file_path IS NOT NULL AND file_path != ''
                GROUP BY format
                ORDER BY count DESC
            """)
            format_breakdown = {row[0]: row[1] for row in cursor.fetchall()}

            # Total duration
            cursor.execute("SELECT COALESCE(SUM(duration), 0) FROM tracks WHERE id IS NOT NULL")
            total_duration_ms = (cursor.fetchone() or [0])[0]

            # Enrichment coverage
            enrichment = {}
            for service, col in [('spotify', 'spotify_artist_id'), ('musicbrainz', 'musicbrainz_id'),
                                 ('deezer', 'deezer_id'), ('lastfm', 'lastfm_url'),
                                 ('itunes', 'itunes_artist_id'), ('audiodb', 'audiodb_id'),
                                 ('genius', 'genius_id'), ('tidal', 'tidal_id'),
                                 ('qobuz', 'qobuz_id')]:
                try:
                    cursor.execute(f"SELECT COUNT(*) FROM artists WHERE {col} IS NOT NULL AND {col} != ''")
                    matched = (cursor.fetchone() or [0])[0]
                    cursor.execute("SELECT COUNT(*) FROM artists WHERE id IS NOT NULL")
                    total_artists = (cursor.fetchone() or [0])[0]
                    enrichment[service] = round(matched / total_artists * 100, 1) if total_artists else 0
                except Exception:
                    enrichment[service] = 0

            return {
                'total_tracks': total_tracks,
                'unplayed_count': unplayed,
                'unplayed_percentage': round(unplayed / total_tracks * 100, 1) if total_tracks else 0,
                'format_breakdown': format_breakdown,
                'total_duration_ms': total_duration_ms,
                'enrichment_coverage': enrichment,
            }
        except Exception as e:
            logger.error(f"Error getting library health: {e}")
            return {}
        finally:
            if conn:
                conn.close()

    def get_db_storage_stats(self):
        """Get database storage breakdown by table."""
        conn = None
        try:
            # Total file size
            total_size = 0
            try:
                total_size = os.path.getsize(str(self.database_path))
            except Exception as e:
                logger.debug("Failed to stat database file size: %s", e)

            conn = self._get_connection()
            cursor = conn.cursor()

            # Try dbstat first (real byte sizes)
            tables = []
            method = 'row_count'
            try:
                cursor.execute("""
                    SELECT name, SUM(pgsize) as size
                    FROM dbstat
                    WHERE name IN (SELECT name FROM sqlite_master WHERE type='table')
                    GROUP BY name
                    ORDER BY size DESC
                """)
                tables = [{'name': r[0], 'size': r[1]} for r in cursor.fetchall()]
                method = 'dbstat'
            except Exception:
                # Fallback: row counts per table
                cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
                for row in cursor.fetchall():
                    tbl = row[0]
                    try:
                        cursor.execute(f"SELECT COUNT(*) FROM [{tbl}]")
                        count = cursor.fetchone()[0]
                        tables.append({'name': tbl, 'size': count})
                    except Exception as e:
                        logger.debug("Failed to get row count for table %s: %s", tbl, e)
                tables.sort(key=lambda x: x['size'], reverse=True)

            return {
                'tables': tables,
                'total_file_size': total_size,
                'method': method,
            }
        except Exception as e:
            logger.error(f"Error getting db storage stats: {e}")
            return {'tables': [], 'total_file_size': 0, 'method': 'error'}
        finally:
            if conn:
                conn.close()

    def get_library_disk_usage(self):
        """Aggregate disk usage of the on-disk music library.

        Returns:
            {
                'total_bytes': int,           # sum of all known file sizes
                'tracks_with_size': int,      # count of tracks with a known size
                'tracks_without_size': int,   # count of tracks where size is NULL
                'by_format': {                # bytes per file extension
                    'flac': int, 'mp3': int, ...
                },
                'has_data': bool,             # False on fresh installs / before first deep scan
            }

        Returns the empty-shape dict when the column doesn't exist (very
        old install pre-migration) — UI shows "(run a Deep Scan)" in
        that case rather than crashing.
        """
        empty = {
            'total_bytes': 0,
            'tracks_with_size': 0,
            'tracks_without_size': 0,
            'by_format': {},
            'has_data': False,
        }
        conn = None
        try:
            conn = self._get_connection()
            cursor = conn.cursor()

            # Confirm column exists (defensive against fresh-install race
            # where the migration hasn't run yet).
            try:
                cursor.execute("SELECT file_size FROM tracks LIMIT 1")
            except Exception:
                return empty

            cursor.execute(
                "SELECT COALESCE(SUM(file_size), 0), "
                "       COUNT(file_size), "
                "       COUNT(*) - COUNT(file_size) "
                "FROM tracks"
            )
            row = cursor.fetchone()
            total_bytes = int(row[0] or 0)
            tracks_with_size = int(row[1] or 0)
            tracks_without_size = int(row[2] or 0)

            # Per-format breakdown via Python aggregation. Doing the
            # extension split in SQLite is fragile (paths with dots
            # before the file extension would group wrong); doing it
            # in Python is one os.path.splitext per row, which is
            # negligible cost compared to the SUM() above.
            cursor.execute(
                "SELECT file_path, file_size FROM tracks "
                "WHERE file_size IS NOT NULL AND file_path IS NOT NULL "
                "      AND file_path != ''"
            )
            by_format: dict = {}
            for path, size in cursor.fetchall():
                ext = os.path.splitext(path)[1].lstrip('.').lower()
                if not ext or len(ext) > 6:
                    continue
                by_format[ext] = by_format.get(ext, 0) + int(size or 0)

            return {
                'total_bytes': total_bytes,
                'tracks_with_size': tracks_with_size,
                'tracks_without_size': tracks_without_size,
                'by_format': by_format,
                'has_data': tracks_with_size > 0,
            }
        except Exception as e:
            logger.error(f"Error getting library disk usage: {e}")
            return empty
        finally:
            if conn:
                conn.close()

    @staticmethod
    def _listening_time_filter(time_range, alias=''):
        """Build a WHERE clause for time-range filtering."""
        prefix = f"{alias}." if alias else ""
        if time_range == '7d':
            return f"WHERE {prefix}played_at >= datetime('now', '-7 days')"
        elif time_range == '30d':
            return f"WHERE {prefix}played_at >= datetime('now', '-30 days')"
        elif time_range == '12m':
            return f"WHERE {prefix}played_at >= datetime('now', '-12 months')"
        else:
            return "WHERE 1=1"

    def set_profile_spotify(self, profile_id: int, client_id: str, client_secret: str,
                            redirect_uri: str = '') -> bool:
        """Save Spotify API credentials for a profile (encrypted)."""
        try:
            from config.settings import config_manager
            enc_id = config_manager._encrypt_value(client_id) if client_id else None
            enc_secret = config_manager._encrypt_value(client_secret) if client_secret else None
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    UPDATE profiles
                    SET spotify_client_id = ?, spotify_client_secret = ?, spotify_redirect_uri = ?,
                        updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                """, (enc_id, enc_secret, redirect_uri or None, profile_id))
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error setting Spotify credentials for profile {profile_id}: {e}")
            return False

    def get_profile_spotify(self, profile_id: int) -> Dict[str, Any]:
        """Get decrypted Spotify credentials for a profile."""
        try:
            from config.settings import config_manager
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT spotify_client_id, spotify_client_secret, spotify_redirect_uri,
                           spotify_access_token, spotify_refresh_token
                    FROM profiles WHERE id = ?
                """, (profile_id,))
                row = cursor.fetchone()
                if not row or not row[0]:
                    return {}
                return {
                    'client_id': config_manager._decrypt_value(row[0]) if row[0] else '',
                    'client_secret': config_manager._decrypt_value(row[1]) if row[1] else '',
                    'redirect_uri': row[2] or '',
                    'access_token': config_manager._decrypt_value(row[3]) if row[3] else '',
                    'refresh_token': config_manager._decrypt_value(row[4]) if row[4] else '',
                }
        except Exception as e:
            logger.error(f"Error getting Spotify credentials for profile {profile_id}: {e}")
            return {}

    def set_profile_spotify_tokens(self, profile_id: int, access_token: str, refresh_token: str) -> bool:
        """Save Spotify OAuth tokens for a profile (from auth callback)."""
        try:
            from config.settings import config_manager
            enc_access = config_manager._encrypt_value(access_token) if access_token else None
            enc_refresh = config_manager._encrypt_value(refresh_token) if refresh_token else None
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    UPDATE profiles
                    SET spotify_access_token = ?, spotify_refresh_token = ?,
                        updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                """, (enc_access, enc_refresh, profile_id))
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error setting Spotify tokens for profile {profile_id}: {e}")
            return False

    def set_profile_server_library(self, profile_id: int, server_type: str,
                                    library_id: str = None, user_id: str = None) -> bool:
        """Save media server library/user selection for a profile."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                if server_type == 'plex':
                    cursor.execute("UPDATE profiles SET plex_library_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
                                   (library_id, profile_id))
                elif server_type == 'jellyfin':
                    cursor.execute("UPDATE profiles SET jellyfin_user_id = ?, jellyfin_library_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
                                   (user_id, library_id, profile_id))
                elif server_type == 'navidrome':
                    cursor.execute("UPDATE profiles SET navidrome_library_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
                                   (library_id, profile_id))
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error setting server library for profile {profile_id}: {e}")
            return False

    def get_profile_server_library(self, profile_id: int) -> Dict[str, Any]:
        """Get media server library/user selection for a profile."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT plex_library_id, jellyfin_user_id, jellyfin_library_id, navidrome_library_id
                    FROM profiles WHERE id = ?
                """, (profile_id,))
                row = cursor.fetchone()
                if not row:
                    return {}
                return {
                    'plex_library_id': row[0],
                    'jellyfin_user_id': row[1],
                    'jellyfin_library_id': row[2],
                    'navidrome_library_id': row[3],
                }
        except Exception as e:
            logger.error(f"Error getting server library for profile {profile_id}: {e}")
            return {}

    def _add_spotify_library_cache_table(self, cursor):
        """Create spotify_library_cache table for caching user's saved Spotify albums"""
        try:
            cursor.execute("SELECT value FROM metadata WHERE key = 'spotify_library_cache_v1' LIMIT 1")
            if cursor.fetchone():
                return  # Already migrated

            logger.info("Creating spotify_library_cache table...")

            cursor.execute("""
                CREATE TABLE IF NOT EXISTS spotify_library_cache (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    spotify_album_id TEXT NOT NULL,
                    album_name TEXT NOT NULL,
                    artist_name TEXT NOT NULL,
                    artist_id TEXT,
                    release_date TEXT,
                    total_tracks INTEGER DEFAULT 0,
                    album_type TEXT DEFAULT 'album',
                    image_url TEXT,
                    date_saved TEXT,
                    cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    profile_id INTEGER DEFAULT 1,
                    UNIQUE(spotify_album_id, profile_id)
                )
            """)

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_spotify_library_album_id ON spotify_library_cache (spotify_album_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_spotify_library_profile ON spotify_library_cache (profile_id)")

            cursor.execute("INSERT OR REPLACE INTO metadata (key, value) VALUES ('spotify_library_cache_v1', '1')")

            logger.info("spotify_library_cache table created successfully")

        except Exception as e:
            logger.error(f"Error creating spotify_library_cache table: {e}")

    def _add_metadata_cache_tables(self, cursor):
        """Create metadata_cache_entities and metadata_cache_searches tables for universal API response caching"""
        try:
            cursor.execute("SELECT value FROM metadata WHERE key = 'metadata_cache_v1' LIMIT 1")
            if cursor.fetchone():
                return  # Already migrated

            logger.info("Creating metadata cache tables...")

            cursor.execute("""
                CREATE TABLE IF NOT EXISTS metadata_cache_entities (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    source TEXT NOT NULL,
                    entity_type TEXT NOT NULL,
                    entity_id TEXT NOT NULL,
                    name TEXT NOT NULL,
                    image_url TEXT,
                    external_urls TEXT,
                    genres TEXT,
                    popularity INTEGER,
                    followers INTEGER,
                    artist_name TEXT,
                    artist_id TEXT,
                    release_date TEXT,
                    total_tracks INTEGER,
                    album_type TEXT,
                    label TEXT,
                    album_name TEXT,
                    album_id TEXT,
                    duration_ms INTEGER,
                    track_number INTEGER,
                    disc_number INTEGER,
                    explicit INTEGER,
                    isrc TEXT,
                    preview_url TEXT,
                    raw_json TEXT NOT NULL,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    last_accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    access_count INTEGER DEFAULT 1,
                    ttl_days INTEGER DEFAULT 30,
                    UNIQUE(source, entity_type, entity_id)
                )
            """)

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mce_lookup ON metadata_cache_entities (source, entity_type, entity_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mce_name ON metadata_cache_entities (entity_type, name COLLATE NOCASE)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mce_artist ON metadata_cache_entities (artist_name COLLATE NOCASE)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mce_accessed ON metadata_cache_entities (last_accessed_at)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mce_source ON metadata_cache_entities (source)")
            # Composite indexes for browse queries (entity_type + sort column)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mce_browse ON metadata_cache_entities (entity_type, source, last_accessed_at DESC)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mce_browse_name ON metadata_cache_entities (entity_type, source, name COLLATE NOCASE)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mce_browse_pop ON metadata_cache_entities (entity_type, source, popularity DESC)")
            # Stats query index (covers GROUP BY entity_type, source with count)
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mce_stats ON metadata_cache_entities (entity_type, source, access_count)")

            cursor.execute("""
                CREATE TABLE IF NOT EXISTS metadata_cache_searches (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    source TEXT NOT NULL,
                    search_type TEXT NOT NULL,
                    query_normalized TEXT NOT NULL,
                    query_original TEXT NOT NULL,
                    result_ids TEXT NOT NULL,
                    result_count INTEGER NOT NULL,
                    search_limit INTEGER,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    last_accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    access_count INTEGER DEFAULT 1,
                    ttl_days INTEGER DEFAULT 7,
                    UNIQUE(source, search_type, query_normalized, search_limit)
                )
            """)

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_mcs_lookup ON metadata_cache_searches (source, search_type, query_normalized)")

            cursor.execute("INSERT OR REPLACE INTO metadata (key, value) VALUES ('metadata_cache_v1', '1')")

            logger.info("Metadata cache tables created successfully")

        except Exception as e:
            logger.error(f"Error creating metadata cache tables: {e}")

    def _add_repair_worker_tables(self, cursor):
        """Create repair_findings and repair_job_runs tables for the multi-job repair worker."""
        try:
            cursor.execute("SELECT value FROM metadata WHERE key = 'repair_worker_v2' LIMIT 1")
            if cursor.fetchone():
                return  # Already migrated

            logger.info("Creating repair worker v2 tables...")

            cursor.execute("""
                CREATE TABLE IF NOT EXISTS repair_findings (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    job_id TEXT NOT NULL,
                    finding_type TEXT NOT NULL,
                    severity TEXT NOT NULL DEFAULT 'info',
                    status TEXT NOT NULL DEFAULT 'pending',
                    entity_type TEXT,
                    entity_id TEXT,
                    file_path TEXT,
                    title TEXT NOT NULL,
                    description TEXT,
                    details_json TEXT DEFAULT '{}',
                    user_action TEXT,
                    resolved_at TIMESTAMP,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_rf_job ON repair_findings (job_id)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_rf_status ON repair_findings (status)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_rf_type ON repair_findings (finding_type)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_rf_created ON repair_findings (created_at)")

            cursor.execute("""
                CREATE TABLE IF NOT EXISTS repair_job_runs (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    job_id TEXT NOT NULL,
                    started_at TIMESTAMP NOT NULL,
                    finished_at TIMESTAMP,
                    duration_seconds REAL,
                    items_scanned INTEGER DEFAULT 0,
                    findings_created INTEGER DEFAULT 0,
                    auto_fixed INTEGER DEFAULT 0,
                    errors INTEGER DEFAULT 0,
                    status TEXT NOT NULL DEFAULT 'running'
                )
            """)

            cursor.execute("CREATE INDEX IF NOT EXISTS idx_rjr_job ON repair_job_runs (job_id)")

            cursor.execute("INSERT OR REPLACE INTO metadata (key, value) VALUES ('repair_worker_v2', '1')")

            logger.info("Repair worker v2 tables created successfully")

        except Exception as e:
            logger.error(f"Error creating repair worker v2 tables: {e}")

    def _init_manual_library_match_table(self):
        """Create manual_library_track_matches table and indexes."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    CREATE TABLE IF NOT EXISTS manual_library_track_matches (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        profile_id INTEGER DEFAULT 1,
                        source TEXT NOT NULL,
                        source_track_id TEXT NOT NULL,
                        source_title TEXT,
                        source_artist TEXT,
                        source_album TEXT,
                        source_context_json TEXT,
                        server_source TEXT DEFAULT '',
                        library_track_id INTEGER NOT NULL,
                        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                        UNIQUE(profile_id, source, source_track_id, server_source)
                    )
                """)
                cursor.execute("""
                    CREATE INDEX IF NOT EXISTS idx_mltm_lookup
                    ON manual_library_track_matches (profile_id, source, source_track_id, server_source)
                """)
                cursor.execute("""
                    CREATE INDEX IF NOT EXISTS idx_mltm_lib_track
                    ON manual_library_track_matches (library_track_id)
                """)
        except Exception as e:
            logger.error(f"Error creating manual_library_track_matches table: {e}")

    def save_manual_library_match(self, profile_id: int, source: str, source_track_id: str,
                                   library_track_id: int, **meta) -> bool:
        """Insert or replace a manual match. meta keys: source_title, source_artist,
        source_album, source_context_json, server_source."""
        try:
            with self._get_connection() as conn:
                conn.execute("""
                    INSERT INTO manual_library_track_matches
                        (profile_id, source, source_track_id, library_track_id,
                         source_title, source_artist, source_album,
                         source_context_json, server_source, updated_at)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
                    ON CONFLICT(profile_id, source, source_track_id, server_source)
                    DO UPDATE SET
                        library_track_id = excluded.library_track_id,
                        source_title = excluded.source_title,
                        source_artist = excluded.source_artist,
                        source_album = excluded.source_album,
                        source_context_json = excluded.source_context_json,
                        updated_at = CURRENT_TIMESTAMP
                """, (
                    profile_id, source, source_track_id, library_track_id,
                    meta.get('source_title'), meta.get('source_artist'),
                    meta.get('source_album'), meta.get('source_context_json'),
                    meta.get('server_source', ''),
                ))
                return True
        except Exception as e:
            logger.error(f"save_manual_library_match error: {e}")
            return False

    def get_manual_library_match(self, profile_id: int, source: str,
                                  source_track_id: str, server_source: str = '') -> Optional[Dict[str, Any]]:
        """Return match row dict or None."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT * FROM manual_library_track_matches
                    WHERE profile_id = ? AND source = ? AND source_track_id = ? AND server_source = ?
                """, (profile_id, source, source_track_id, server_source))
                row = cursor.fetchone()
                return dict(row) if row else None
        except Exception as e:
            logger.error(f"get_manual_library_match error: {e}")
            return None

    def find_manual_library_match_by_source_track_id(self, profile_id: int,
                                                     source_track_id: str,
                                                     server_source: str = '') -> Optional[Dict[str, Any]]:
        """Return a manual match for this source track ID across source labels.

        The UI may save a match from sync history as ``mirrored`` while the
        wishlist/download flow later sees the same track under ``wishlist`` or
        the provider name. The source remains useful metadata, but the stored
        track ID is the stable identity we need to honor.
        """
        if not source_track_id:
            return None
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT * FROM manual_library_track_matches
                    WHERE profile_id = ?
                      AND source_track_id = ?
                      AND (server_source = ? OR server_source = '')
                    ORDER BY
                        CASE WHEN server_source = ? THEN 0 ELSE 1 END,
                        updated_at DESC
                    LIMIT 1
                """, (profile_id, source_track_id, server_source or '', server_source or ''))
                row = cursor.fetchone()
                return dict(row) if row else None
        except Exception as e:
            logger.error(f"find_manual_library_match_by_source_track_id error: {e}")
            return None

    def find_manual_library_match_by_metadata(self, profile_id: int,
                                              source_title: str,
                                              source_artist: str,
                                              server_source: str = '') -> Optional[Dict[str, Any]]:
        """Return a manual match by title/artist when provider IDs differ."""
        if not source_title or not source_artist:
            return None
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT * FROM manual_library_track_matches
                    WHERE profile_id = ?
                      AND source_title = ? COLLATE NOCASE
                      AND source_artist = ? COLLATE NOCASE
                      AND (server_source = ? OR server_source = '')
                    ORDER BY
                        CASE WHEN server_source = ? THEN 0 ELSE 1 END,
                        updated_at DESC
                    LIMIT 1
                """, (profile_id, source_title, source_artist, server_source or '', server_source or ''))
                row = cursor.fetchone()
                return dict(row) if row else None
        except Exception as e:
            logger.error(f"find_manual_library_match_by_metadata error: {e}")
            return None

    def delete_manual_library_match(self, match_id: int, profile_id: int) -> bool:
        """Delete match by PK id, scoped to profile_id."""
        try:
            with self._get_connection() as conn:
                conn.execute("""
                    DELETE FROM manual_library_track_matches WHERE id = ? AND profile_id = ?
                """, (match_id, profile_id))
                return True
        except Exception as e:
            logger.error(f"delete_manual_library_match error: {e}")
            return False

    def list_manual_library_matches(self, profile_id: int, limit: int = 100) -> List[Dict[str, Any]]:
        """Return matches for profile ordered by updated_at DESC."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT * FROM manual_library_track_matches
                    WHERE profile_id = ?
                    ORDER BY updated_at DESC
                    LIMIT ?
                """, (profile_id, limit))
                return [dict(r) for r in cursor.fetchall()]
        except Exception as e:
            logger.error(f"list_manual_library_matches error: {e}")
            return []

    # ── Profile CRUD ──────────────────────────────────────────────────

    def get_all_profiles(self) -> List[Dict[str, Any]]:
        """Get all profiles"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='profiles'")
                if not cursor.fetchone():
                    return [{'id': 1, 'name': 'Admin', 'avatar_color': '#6366f1', 'avatar_url': None, 'is_admin': True, 'has_pin': False}]
                cursor.execute("SELECT * FROM profiles ORDER BY id")
                rows = cursor.fetchall()
                columns = [desc[0] for desc in cursor.description]
                results = []
                for row in rows:
                    ap_raw = row['allowed_pages'] if 'allowed_pages' in columns else None
                    results.append({
                        'id': row['id'],
                        'name': row['name'],
                        'avatar_color': row['avatar_color'],
                        'avatar_url': row['avatar_url'] if 'avatar_url' in columns else None,
                        'is_admin': bool(row['is_admin']),
                        'has_pin': row['pin_hash'] is not None,
                        'home_page': row['home_page'] if 'home_page' in columns else None,
                        'allowed_pages': json.loads(ap_raw) if ap_raw else None,
                        'can_download': bool(row['can_download']) if 'can_download' in columns else True,
                        'has_listenbrainz': row['listenbrainz_token'] is not None if 'listenbrainz_token' in columns else False,
                        'listenbrainz_username': row['listenbrainz_username'] if 'listenbrainz_username' in columns else None,
                        'created_at': row['created_at'],
                        'updated_at': row['updated_at'],
                    })
                return results
        except Exception as e:
            logger.error(f"Error getting profiles: {e}")
            return [{'id': 1, 'name': 'Admin', 'avatar_color': '#6366f1', 'avatar_url': None, 'is_admin': True, 'has_pin': False}]

    def get_profile(self, profile_id: int) -> Optional[Dict[str, Any]]:
        """Get a single profile by ID"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("SELECT * FROM profiles WHERE id = ?", (profile_id,))
                row = cursor.fetchone()
                if row:
                    columns = [desc[0] for desc in cursor.description]
                    ap_raw = row['allowed_pages'] if 'allowed_pages' in columns else None
                    return {
                        'id': row['id'],
                        'name': row['name'],
                        'avatar_color': row['avatar_color'],
                        'avatar_url': row['avatar_url'] if 'avatar_url' in columns else None,
                        'is_admin': bool(row['is_admin']),
                        'has_pin': row['pin_hash'] is not None,
                        'home_page': row['home_page'] if 'home_page' in columns else None,
                        'allowed_pages': json.loads(ap_raw) if ap_raw else None,
                        'can_download': bool(row['can_download']) if 'can_download' in columns else True,
                        'has_listenbrainz': row['listenbrainz_token'] is not None if 'listenbrainz_token' in columns else False,
                        'listenbrainz_username': row['listenbrainz_username'] if 'listenbrainz_username' in columns else None,
                        'created_at': row['created_at'],
                        'updated_at': row['updated_at'],
                    }
                return None
        except Exception as e:
            logger.error(f"Error getting profile {profile_id}: {e}")
            return None

    def create_profile(self, name: str, avatar_color: str = '#6366f1',
                       pin_hash: Optional[str] = None, is_admin: bool = False,
                       avatar_url: Optional[str] = None, home_page: Optional[str] = None,
                       allowed_pages: Optional[list] = None, can_download: bool = True) -> Optional[int]:
        """Create a new profile. Returns new profile ID or None on error."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                ap_json = json.dumps(allowed_pages) if allowed_pages is not None else None
                cursor.execute("""
                    INSERT INTO profiles (name, avatar_color, pin_hash, is_admin, avatar_url, home_page, allowed_pages, can_download)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """, (name, avatar_color, pin_hash, int(is_admin), avatar_url, home_page, ap_json, int(can_download)))
                conn.commit()
                return cursor.lastrowid
        except sqlite3.IntegrityError:
            logger.warning(f"Profile name '{name}' already exists")
            return None
        except Exception as e:
            logger.error(f"Error creating profile: {e}")
            return None

    def update_profile(self, profile_id: int, **kwargs) -> bool:
        """Update profile fields. Accepts: name, avatar_color, avatar_url, pin_hash, is_admin, home_page, allowed_pages, can_download."""
        allowed = {'name', 'avatar_color', 'avatar_url', 'pin_hash', 'is_admin', 'home_page', 'allowed_pages', 'can_download'}
        updates = {k: v for k, v in kwargs.items() if k in allowed}
        # Serialize allowed_pages list to JSON string for storage
        if 'allowed_pages' in updates:
            v = updates['allowed_pages']
            updates['allowed_pages'] = json.dumps(v) if v is not None else None
        if not updates:
            return False
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                set_clause = ', '.join(f"{k} = ?" for k in updates)
                values = list(updates.values())
                values.append(profile_id)
                cursor.execute(
                    f"UPDATE profiles SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
                    values
                )
                conn.commit()
                return cursor.rowcount > 0
        except sqlite3.IntegrityError:
            logger.warning("Profile update failed (duplicate name?)")
            return False
        except Exception as e:
            logger.error(f"Error updating profile {profile_id}: {e}")
            return False

    def delete_profile(self, profile_id: int) -> bool:
        """Delete a profile and all its per-profile data."""
        if profile_id == 1:
            return False  # Cannot delete the default admin profile
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                # Delete per-profile data from all tables
                for table in ['watchlist_artists', 'wishlist_tracks', 'similar_artists',
                              'discovery_pool', 'discovery_recent_albums', 'discovery_curated_playlists',
                              'bubble_snapshots', 'recent_releases']:
                    try:
                        cursor.execute(f"DELETE FROM {table} WHERE profile_id = ?", (profile_id,))
                    except Exception as e:
                        logger.debug("Failed to delete from %s for profile: %s", table, e)
                cursor.execute("DELETE FROM profiles WHERE id = ?", (profile_id,))
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error deleting profile {profile_id}: {e}")
            return False

    def verify_profile_pin(self, profile_id: int, pin: str) -> bool:
        """Verify a profile's PIN"""
        try:
            from werkzeug.security import check_password_hash
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("SELECT pin_hash FROM profiles WHERE id = ?", (profile_id,))
                row = cursor.fetchone()
                if not row or not row['pin_hash']:
                    return True  # No PIN set = always valid
                return check_password_hash(row['pin_hash'], pin)
        except Exception as e:
            logger.error(f"Error verifying PIN for profile {profile_id}: {e}")
            return False

    def close(self):
        """Close database connection (no-op since we create connections per operation)"""
        # Each operation creates and closes its own connection, so nothing to do here
        pass
    
    def get_statistics(self) -> Dict[str, int]:
        """Get database statistics for all servers (legacy method)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                
                cursor.execute("SELECT COUNT(DISTINCT name) FROM artists")
                artist_count = cursor.fetchone()[0]
                
                cursor.execute("SELECT COUNT(*) FROM albums")
                album_count = cursor.fetchone()[0]
                
                cursor.execute("SELECT COUNT(*) FROM tracks")
                track_count = cursor.fetchone()[0]
                
                return {
                    'artists': artist_count,
                    'albums': album_count,
                    'tracks': track_count
                }
        except Exception as e:
            logger.error(f"Error getting database statistics: {e}")
            return {'artists': 0, 'albums': 0, 'tracks': 0}
    
    def get_statistics_for_server(self, server_source: str = None) -> Dict[str, int]:
        """Get database statistics filtered by server source"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                
                if server_source:
                    # Get counts for specific server (deduplicate by name like general count)
                    cursor.execute("SELECT COUNT(DISTINCT name) FROM artists WHERE server_source = ?", (server_source,))
                    artist_count = cursor.fetchone()[0]
                    
                    cursor.execute("SELECT COUNT(*) FROM albums WHERE server_source = ?", (server_source,))
                    album_count = cursor.fetchone()[0]
                    
                    cursor.execute("SELECT COUNT(*) FROM tracks WHERE server_source = ?", (server_source,))
                    track_count = cursor.fetchone()[0]
                else:
                    # Get total counts (all servers)
                    cursor.execute("SELECT COUNT(*) FROM artists")
                    artist_count = cursor.fetchone()[0]
                    
                    cursor.execute("SELECT COUNT(*) FROM albums")
                    album_count = cursor.fetchone()[0]
                    
                    cursor.execute("SELECT COUNT(*) FROM tracks")
                    track_count = cursor.fetchone()[0]
                
                return {
                    'artists': artist_count,
                    'albums': album_count,
                    'tracks': track_count
                }
        except Exception as e:
            logger.error(f"Error getting database statistics for {server_source}: {e}")
            return {'artists': 0, 'albums': 0, 'tracks': 0}
    
    def clear_all_data(self):
        """Clear all data from database (for full refresh) - DEPRECATED: Use clear_server_data instead"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                
                cursor.execute("DELETE FROM tracks")
                cursor.execute("DELETE FROM albums")
                cursor.execute("DELETE FROM artists")
                
                conn.commit()
                
                # VACUUM to actually shrink the database file and reclaim disk space
                logger.info("Vacuuming database to reclaim disk space...")
                self._vacuum_best_effort(cursor)
                
                logger.info("All database data cleared and file compacted")
                
        except Exception as e:
            logger.error(f"Error clearing database: {e}")
            raise

    def _vacuum_best_effort(self, cursor):
        """Run VACUUM without making the caller fail if compaction hiccups."""
        try:
            cursor.execute("VACUUM")
        except Exception as e:
            logger.warning(
                "Database VACUUM failed after data was already cleared; continuing without compaction: %s",
                e,
            )

    @staticmethod
    def _is_transient_sqlite_io_error(exc: Exception) -> bool:
        return "disk i/o error" in str(exc).lower()
    
    def clear_server_data(self, server_source: str):
        """Clear data for specific server only (server-aware full refresh)"""
        for attempt in range(2):
            try:
                with self._get_connection() as conn:
                    cursor = conn.cursor()

                    # Delete only data from the specified server
                    # Order matters: tracks -> albums -> artists (foreign key constraints)
                    cursor.execute("DELETE FROM tracks WHERE server_source = ?", (server_source,))
                    tracks_deleted = cursor.rowcount

                    cursor.execute("DELETE FROM albums WHERE server_source = ?", (server_source,))
                    albums_deleted = cursor.rowcount

                    cursor.execute("DELETE FROM artists WHERE server_source = ?", (server_source,))
                    artists_deleted = cursor.rowcount

                    conn.commit()

                    # Only VACUUM if we deleted a significant amount of data
                    if tracks_deleted > 1000 or albums_deleted > 100:
                        logger.info("Vacuuming database to reclaim disk space...")
                        self._vacuum_best_effort(cursor)

                    logger.info(
                        f"Cleared {server_source} data: {artists_deleted} artists, "
                        f"{albums_deleted} albums, {tracks_deleted} tracks"
                    )

                    # Note: Watchlist and wishlist are preserved as they are server-agnostic
                    return

            except Exception as e:
                if self._is_transient_sqlite_io_error(e) and attempt == 0:
                    logger.warning(
                        "Transient disk I/O error clearing %s database data; retrying once: %s",
                        server_source,
                        e,
                    )
                    time.sleep(0.25)
                    continue
                logger.error(f"Error clearing {server_source} database data: {e}")
                raise
    
    def cleanup_orphaned_records(self) -> Dict[str, int]:
        """Remove artists and albums that have no associated tracks"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                
                # Find orphaned artists (no tracks)
                cursor.execute("""
                    SELECT COUNT(*) FROM artists 
                    WHERE id NOT IN (SELECT DISTINCT artist_id FROM tracks WHERE artist_id IS NOT NULL)
                """)
                orphaned_artists_count = cursor.fetchone()[0]
                
                # Find orphaned albums (no tracks)
                cursor.execute("""
                    SELECT COUNT(*) FROM albums 
                    WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks WHERE album_id IS NOT NULL)
                """)
                orphaned_albums_count = cursor.fetchone()[0]
                
                # Delete orphaned artists
                if orphaned_artists_count > 0:
                    cursor.execute("""
                        DELETE FROM artists 
                        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")
                
                # Delete orphaned albums  
                if orphaned_albums_count > 0:
                    cursor.execute("""
                        DELETE FROM albums 
                        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")
                
                conn.commit()
                
                return {
                    'orphaned_artists_removed': orphaned_artists_count,
                    'orphaned_albums_removed': orphaned_albums_count
                }
                
        except Exception as e:
            logger.error(f"Error cleaning up orphaned records: {e}")
            return {'orphaned_artists_removed': 0, 'orphaned_albums_removed': 0}
    
    def merge_duplicate_artists(self) -> Dict[str, int]:
        """
        Find and merge duplicate artists that share the same name + server_source.
        Keeps the artist with the most enrichment data, migrates albums/tracks,
        and merges enrichment columns.
        """
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Find duplicate artist groups (same name + server_source, different IDs)
                cursor.execute("""
                    SELECT name, server_source, GROUP_CONCAT(id) as ids, COUNT(*) as cnt
                    FROM artists
                    GROUP BY name, server_source
                    HAVING cnt > 1
                """)
                duplicate_groups = cursor.fetchall()

                if not duplicate_groups:
                    logger.debug("No duplicate artists found")
                    return {'artists_merged': 0, 'albums_migrated': 0}

                total_merged = 0
                total_albums_migrated = 0

                enrichment_cols = [
                    'musicbrainz_id', 'musicbrainz_last_attempted', 'musicbrainz_match_status',
                    'spotify_artist_id', 'spotify_match_status', 'spotify_last_attempted',
                    'itunes_artist_id', 'itunes_match_status', 'itunes_last_attempted',
                    'audiodb_id', 'audiodb_match_status', 'audiodb_last_attempted',
                    'style', 'mood', 'label', 'banner_url',
                    'deezer_id', 'deezer_match_status', 'deezer_last_attempted',
                ]

                for group in duplicate_groups:
                    artist_name = group['name']
                    server_source = group['server_source']
                    ids = group['ids'].split(',')

                    logger.info(f"Merging duplicate artist '{artist_name}' ({server_source}): IDs {ids}")

                    # Pick the keeper: the one with the most enrichment data
                    best_id = ids[0]
                    best_score = 0
                    for aid in ids:
                        cursor.execute("SELECT * FROM artists WHERE id = ?", (aid,))
                        row = cursor.fetchone()
                        if row:
                            score = 0
                            for col in enrichment_cols:
                                try:
                                    if row[col] is not None:
                                        score += 1
                                except (IndexError, KeyError):
                                    continue
                            if score > best_score:
                                best_score = score
                                best_id = aid

                    # Merge enrichment data from all duplicates into the keeper
                    for aid in ids:
                        if aid == best_id:
                            continue
                        cursor.execute("SELECT * FROM artists WHERE id = ?", (aid,))
                        donor = cursor.fetchone()
                        if not donor:
                            continue

                        # Fill NULL enrichment columns on keeper from this duplicate
                        set_parts = []
                        values = []
                        for col in enrichment_cols:
                            try:
                                donor_val = donor[col]
                                if donor_val is not None:
                                    # Only fill if keeper's value is NULL
                                    set_parts.append(f"{col} = COALESCE({col}, ?)")
                                    values.append(donor_val)
                            except (IndexError, KeyError):
                                continue

                        if set_parts:
                            values.append(best_id)
                            cursor.execute(f"""
                                UPDATE artists SET {', '.join(set_parts)}
                                WHERE id = ?
                            """, values)

                        # Migrate albums and tracks from duplicate to keeper
                        cursor.execute("UPDATE albums SET artist_id = ? WHERE artist_id = ?", (best_id, aid))
                        migrated = cursor.rowcount
                        total_albums_migrated += migrated
                        cursor.execute("UPDATE tracks SET artist_id = ? WHERE artist_id = ?", (best_id, aid))

                        # Delete the duplicate artist
                        cursor.execute("SELECT COUNT(*) FROM albums WHERE artist_id = ?", (aid,))
                        remaining = cursor.fetchone()[0]
                        if remaining == 0:
                            cursor.execute("DELETE FROM artists WHERE id = ?", (aid,))
                            total_merged += 1
                            logger.info(f"   Merged '{artist_name}' ID {aid} → {best_id} ({migrated} albums migrated)")
                        else:
                            logger.warning(f"   Could not delete duplicate {aid}: {remaining} albums still reference it")

                conn.commit()

                if total_merged > 0:
                    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}

        except Exception as e:
            logger.error(f"Error merging duplicate artists: {e}")
            return {'artists_merged': 0, 'albums_migrated': 0}

    # --- Removal detection helpers ---

    def get_all_artist_ids_for_server(self, server_source: str) -> set:
        """Get all artist IDs stored in the database for a specific server."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("SELECT id FROM artists WHERE server_source = ?", (server_source,))
                return {row[0] for row in cursor.fetchall()}
        except Exception as e:
            logger.error(f"Error getting artist IDs for {server_source}: {e}")
            return set()

    def get_all_album_ids_for_server(self, server_source: str) -> set:
        """Get all album IDs stored in the database for a specific server."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("SELECT id FROM albums WHERE server_source = ?", (server_source,))
                return {row[0] for row in cursor.fetchall()}
        except Exception as e:
            logger.error(f"Error getting album IDs for {server_source}: {e}")
            return set()

    def get_all_track_ids_for_server(self, server_source: str) -> set:
        """Get all track IDs stored in the database for a specific server."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("SELECT id FROM tracks WHERE server_source = ?", (server_source,))
                return {row[0] for row in cursor.fetchall()}
        except Exception as e:
            logger.error(f"Error getting track IDs for {server_source}: {e}")
            return set()

    def delete_stale_tracks(self, stale_track_ids: set, server_source: str) -> int:
        """Delete tracks by ID+server_source that no longer exist on the media server.
        Processes in batches of 500 for database safety."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                batch_size = 500
                tracks_removed = 0

                track_list = list(stale_track_ids)
                for i in range(0, len(track_list), batch_size):
                    batch = track_list[i:i + batch_size]
                    placeholders = ','.join('?' * len(batch))
                    params = batch + [server_source]

                    cursor.execute(
                        f"DELETE FROM tracks WHERE id IN ({placeholders}) AND server_source = ?",
                        params)
                    tracks_removed += cursor.rowcount

                conn.commit()

                if tracks_removed > 0:
                    logger.info(f"Deep scan stale removal for {server_source}: "
                                f"{tracks_removed} tracks removed")

                return tracks_removed

        except Exception as e:
            logger.error(f"Error deleting stale tracks for {server_source}: {e}")
            return 0

    def delete_removed_content(self, removed_artist_ids: set, removed_album_ids: set,
                               server_source: str):
        """Delete artists and albums that were removed from the media server.
        Manually cascades deletes (tracks -> albums -> artists) to match existing patterns."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                batch_size = 500

                artists_removed = 0
                albums_removed = 0
                tracks_removed = 0

                # Remove artists and their children
                if removed_artist_ids:
                    artist_list = list(removed_artist_ids)
                    for i in range(0, len(artist_list), batch_size):
                        batch = artist_list[i:i + batch_size]
                        placeholders = ','.join('?' * len(batch))
                        params = batch + [server_source]

                        # Delete tracks belonging to these artists
                        cursor.execute(
                            f"SELECT COUNT(*) FROM tracks WHERE artist_id IN ({placeholders}) AND server_source = ?",
                            params)
                        tracks_removed += cursor.fetchone()[0]
                        cursor.execute(
                            f"DELETE FROM tracks WHERE artist_id IN ({placeholders}) AND server_source = ?",
                            params)

                        # Delete albums belonging to these artists
                        cursor.execute(
                            f"SELECT COUNT(*) FROM albums WHERE artist_id IN ({placeholders}) AND server_source = ?",
                            params)
                        albums_removed += cursor.fetchone()[0]
                        cursor.execute(
                            f"DELETE FROM albums WHERE artist_id IN ({placeholders}) AND server_source = ?",
                            params)

                        # Delete the artists themselves
                        cursor.execute(
                            f"DELETE FROM artists WHERE id IN ({placeholders}) AND server_source = ?",
                            params)
                        artists_removed += cursor.rowcount

                # Remove albums (not already handled by artist cascade above)
                if removed_album_ids:
                    album_list = list(removed_album_ids)
                    for i in range(0, len(album_list), batch_size):
                        batch = album_list[i:i + batch_size]
                        placeholders = ','.join('?' * len(batch))
                        params = batch + [server_source]

                        # Delete tracks belonging to these albums
                        cursor.execute(
                            f"SELECT COUNT(*) FROM tracks WHERE album_id IN ({placeholders}) AND server_source = ?",
                            params)
                        tracks_removed += cursor.fetchone()[0]
                        cursor.execute(
                            f"DELETE FROM tracks WHERE album_id IN ({placeholders}) AND server_source = ?",
                            params)

                        # Delete the albums themselves
                        cursor.execute(
                            f"DELETE FROM albums WHERE id IN ({placeholders}) AND server_source = ?",
                            params)
                        albums_removed += cursor.rowcount

                conn.commit()

                if artists_removed > 0 or albums_removed > 0:
                    logger.info(f"Removal cleanup for {server_source}: "
                                f"{artists_removed} artists, {albums_removed} albums, "
                                f"{tracks_removed} tracks removed")

                return {
                    'artists_removed': artists_removed,
                    'albums_removed': albums_removed,
                    'tracks_removed': tracks_removed
                }

        except Exception as e:
            logger.error(f"Error deleting removed content for {server_source}: {e}")
            return {'artists_removed': 0, 'albums_removed': 0, 'tracks_removed': 0}

    # Artist operations
    def insert_or_update_artist(self, plex_artist) -> bool:
        """Insert or update artist from Plex artist object - DEPRECATED: Use insert_or_update_media_artist instead"""
        return self.insert_or_update_media_artist(plex_artist, server_source='plex')
    
    def insert_or_update_media_artist(self, artist_obj, server_source: str = 'plex') -> bool:
        """Insert or update artist from media server artist object (Plex or Jellyfin)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                
                # Convert artist ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
                artist_id = str(artist_obj.ratingKey)
                raw_name = artist_obj.title
                # Normalize artist name to handle quote variations and other inconsistencies
                name = self._normalize_artist_name(raw_name)

                # Debug logging to see if normalization is working
                if raw_name != name:
                    logger.info(f"Artist name normalized: '{raw_name}' -> '{name}'")
                thumb_url = getattr(artist_obj, 'thumb', None)
                
                # Only preserve timestamps and flags from summary, not full biography
                full_summary = getattr(artist_obj, 'summary', None) or ''
                summary = None
                if full_summary:
                    # Extract only our tracking markers (timestamps and ignore flags)
                    import re
                    markers = []
                    
                    # Extract timestamp marker
                    timestamp_match = re.search(r'-updatedAt\d{4}-\d{2}-\d{2}', full_summary)
                    if timestamp_match:
                        markers.append(timestamp_match.group(0))
                    
                    # Extract ignore flag
                    if '-IgnoreUpdate' in full_summary:
                        markers.append('-IgnoreUpdate')
                    
                    # Only store markers, not full biography
                    summary = '\n\n'.join(markers) if markers else None
                
                # Get genres (handle both Plex and Jellyfin formats)
                genres = []
                if hasattr(artist_obj, 'genres') and artist_obj.genres:
                    genres = [genre.tag if hasattr(genre, 'tag') else str(genre) 
                             for genre in artist_obj.genres]
                
                genres_json = json.dumps(genres) if genres else None
                
                # Check if artist exists with this ID and server source
                cursor.execute("SELECT id FROM artists WHERE id = ? AND server_source = ?", (artist_id, server_source))
                exists = cursor.fetchone()

                if exists:
                    # Update existing artist
                    cursor.execute("""
                        UPDATE artists
                        SET name = ?, thumb_url = ?, genres = ?, summary = ?, updated_at = CURRENT_TIMESTAMP
                        WHERE id = ? AND server_source = ?
                    """, (name, thumb_url, genres_json, summary, artist_id, server_source))
                    logger.debug(f"Updated existing {server_source} artist: {name} (ID: {artist_id})")
                else:
                    # Before inserting, check if an artist with the same name already exists
                    # for this server source (ratingKey may have changed after a library rescan)
                    cursor.execute("SELECT id FROM artists WHERE name = ? AND server_source = ?", (name, server_source))
                    existing_by_name = cursor.fetchone()

                    if existing_by_name:
                        old_id = existing_by_name['id']
                        # ratingKey changed — migrate old artist to new ID, preserving enrichment data
                        logger.info(f"Artist ratingKey migrated: '{name}' ({old_id} → {artist_id})")

                        # Step 1: Insert new artist record, copying enrichment data from old
                        enrichment_cols = [
                            'musicbrainz_id', 'musicbrainz_last_attempted', 'musicbrainz_match_status',
                            'spotify_artist_id', 'spotify_match_status', 'spotify_last_attempted',
                            'itunes_artist_id', 'itunes_match_status', 'itunes_last_attempted',
                            'audiodb_id', 'audiodb_match_status', 'audiodb_last_attempted',
                            'style', 'mood', 'label', 'banner_url',
                            'deezer_id', 'deezer_match_status', 'deezer_last_attempted',
                        ]

                        # Read enrichment data from old artist
                        cursor.execute("SELECT * FROM artists WHERE id = ? AND server_source = ?", (old_id, server_source))
                        old_row = cursor.fetchone()

                        # Insert new artist with fresh server metadata + preserved created_at
                        old_created = old_row['created_at'] if old_row else None
                        cursor.execute("""
                            INSERT INTO artists (id, name, thumb_url, genres, summary, server_source, created_at, updated_at)
                            VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
                        """, (artist_id, name, thumb_url, genres_json, summary, server_source, old_created))

                        # Copy enrichment data from old record to new record
                        if old_row:
                            set_parts = []
                            values = []
                            for col in enrichment_cols:
                                try:
                                    val = old_row[col]
                                    if val is not None:
                                        set_parts.append(f"{col} = ?")
                                        values.append(val)
                                except (IndexError, KeyError):
                                    continue  # Column doesn't exist in this DB version

                            if set_parts:
                                values.append(artist_id)
                                cursor.execute(f"""
                                    UPDATE artists SET {', '.join(set_parts)}
                                    WHERE id = ?
                                """, values)

                        # Step 2: Migrate album and track references to new artist ID
                        cursor.execute("UPDATE albums SET artist_id = ? WHERE artist_id = ?", (artist_id, old_id))
                        migrated_albums = cursor.rowcount
                        cursor.execute("UPDATE tracks SET artist_id = ? WHERE artist_id = ?", (artist_id, old_id))
                        migrated_tracks = cursor.rowcount

                        # Step 3: Safely delete old artist (verify no remaining references first)
                        cursor.execute("SELECT COUNT(*) FROM albums WHERE artist_id = ?", (old_id,))
                        remaining = cursor.fetchone()[0]
                        if remaining == 0:
                            cursor.execute("DELETE FROM artists WHERE id = ? AND server_source = ?", (old_id, server_source))
                        else:
                            logger.warning(f"Could not delete old artist {old_id}: {remaining} albums still reference it")

                        if migrated_albums > 0 or migrated_tracks > 0:
                            logger.info(f"   Migrated {migrated_albums} albums, {migrated_tracks} tracks to new ID")
                    else:
                        # Genuinely new artist — insert fresh record
                        cursor.execute("""
                            INSERT INTO artists (id, name, thumb_url, genres, summary, server_source)
                            VALUES (?, ?, ?, ?, ?, ?)
                        """, (artist_id, name, thumb_url, genres_json, summary, server_source))
                        logger.debug(f"Inserted new {server_source} artist: {name} (ID: {artist_id})")

                conn.commit()
                rows_affected = cursor.rowcount
                if rows_affected == 0:
                    logger.warning(f"Database insertion returned 0 rows affected for {server_source} artist: {name} (ID: {artist_id})")

                return True
                
        except Exception as e:
            logger.error(f"Error inserting/updating {server_source} artist {getattr(artist_obj, 'title', 'Unknown')}: {e}")
            return False

    def _normalize_artist_name(self, name: str) -> str:
        """
        Normalize artist names to handle inconsistencies like quote variations.
        Converts Unicode smart quotes to ASCII quotes for consistency.
        """
        if not name:
            return name

        # Replace Unicode smart quotes with regular ASCII quotes
        normalized = name.replace('\u201c', '"').replace('\u201d', '"')  # Left and right double quotes
        normalized = normalized.replace('\u2018', "'").replace('\u2019', "'")  # Left and right single quotes
        normalized = normalized.replace('\u00ab', '"').replace('\u00bb', '"')  # « » guillemets

        return normalized
    
    def get_artist(self, artist_id: int) -> Optional[DatabaseArtist]:
        """Get artist by ID"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                
                cursor.execute("SELECT * FROM artists WHERE id = ?", (artist_id,))
                row = cursor.fetchone()
                
                if row:
                    genres = json.loads(row['genres']) if row['genres'] else None
                    return DatabaseArtist(
                        id=row['id'],
                        name=row['name'],
                        thumb_url=row['thumb_url'],
                        genres=genres,
                        summary=row['summary'],
                        created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
                        updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None
                    )
                return None
                
        except Exception as e:
            logger.error(f"Error getting artist {artist_id}: {e}")
            return None
    
    # Album operations
    def insert_or_update_album(self, plex_album, artist_id: int) -> bool:
        """Insert or update album from Plex album object - DEPRECATED: Use insert_or_update_media_album instead"""
        return self.insert_or_update_media_album(plex_album, artist_id, server_source='plex')
    
    def insert_or_update_media_album(self, album_obj, artist_id: str, server_source: str = 'plex') -> bool:
        """Insert or update album from media server album object (Plex or Jellyfin)"""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            
            # Convert album ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
            album_id = str(album_obj.ratingKey)
            title = album_obj.title
            year = getattr(album_obj, 'year', None)
            thumb_url = getattr(album_obj, 'thumb', None)
            
            # Get track count and duration (handle different server attributes)
            track_count = getattr(album_obj, 'leafCount', None) or getattr(album_obj, 'childCount', None)
            duration = getattr(album_obj, 'duration', None)
            
            # Get genres (handle both Plex and Jellyfin formats)
            genres = []
            if hasattr(album_obj, 'genres') and album_obj.genres:
                genres = [genre.tag if hasattr(genre, 'tag') else str(genre) 
                         for genre in album_obj.genres]
            
            genres_json = json.dumps(genres) if genres else None
            
            # Check if album exists with this ID (PRIMARY KEY check)
            cursor.execute("SELECT id, server_source FROM albums WHERE id = ?", (album_id,))
            existing = cursor.fetchone()

            if existing:
                # Album exists - update it (update server_source if different)
                cursor.execute("""
                    UPDATE albums
                    SET artist_id = ?, title = ?, year = ?, thumb_url = ?, genres = ?,
                        track_count = ?, duration = ?, server_source = ?, updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                """, (artist_id, title, year, thumb_url, genres_json, track_count, duration, server_source, album_id))
            else:
                # Before inserting, check if an album with the same title already exists
                # under this artist (ratingKey may have changed after a library rescan)
                cursor.execute(
                    "SELECT id FROM albums WHERE title = ? AND artist_id = ? AND server_source = ?",
                    (title, artist_id, server_source))
                existing_by_title = cursor.fetchone()

                if existing_by_title:
                    old_id = existing_by_title['id']
                    # ratingKey changed — migrate old album to new ID, preserving enrichment data
                    logger.info(f"Album ratingKey migrated: '{title}' ({old_id} → {album_id})")

                    enrichment_cols = [
                        'musicbrainz_release_id', 'musicbrainz_last_attempted', 'musicbrainz_match_status',
                        'spotify_album_id', 'spotify_match_status', 'spotify_last_attempted',
                        'itunes_album_id', 'itunes_match_status', 'itunes_last_attempted',
                        'audiodb_id', 'audiodb_match_status', 'audiodb_last_attempted',
                        'style', 'mood', 'label', 'explicit', 'record_type',
                        'deezer_id', 'deezer_match_status', 'deezer_last_attempted',
                        # api_track_count is metadata-source-derived enrichment cache;
                        # losing it on a ratingKey rekey would force the next
                        # completeness scan back to live API lookups (kettui PR #374).
                        'api_track_count',
                    ]

                    # Read enrichment data from old album
                    cursor.execute("SELECT * FROM albums WHERE id = ?", (old_id,))
                    old_row = cursor.fetchone()

                    # Insert new album with fresh server metadata + preserved created_at
                    old_created = old_row['created_at'] if old_row else None
                    cursor.execute("""
                        INSERT INTO albums (id, artist_id, title, year, thumb_url, genres,
                                            track_count, duration, server_source, created_at, updated_at)
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
                    """, (album_id, artist_id, title, year, thumb_url, genres_json,
                          track_count, duration, server_source, old_created))

                    # Copy enrichment data from old record to new record
                    if old_row:
                        set_parts = []
                        values = []
                        for col in enrichment_cols:
                            try:
                                val = old_row[col]
                                if val is not None:
                                    set_parts.append(f"{col} = ?")
                                    values.append(val)
                            except (IndexError, KeyError):
                                continue  # Column doesn't exist in this DB version

                        if set_parts:
                            values.append(album_id)
                            cursor.execute(f"""
                                UPDATE albums SET {', '.join(set_parts)}
                                WHERE id = ?
                            """, values)

                    # Migrate track references to new album ID
                    cursor.execute("UPDATE tracks SET album_id = ? WHERE album_id = ?", (album_id, old_id))
                    migrated_tracks = cursor.rowcount

                    # Safely delete old album (verify no remaining references first)
                    cursor.execute("SELECT COUNT(*) FROM tracks WHERE album_id = ?", (old_id,))
                    remaining = cursor.fetchone()[0]
                    if remaining == 0:
                        cursor.execute("DELETE FROM albums WHERE id = ?", (old_id,))
                    else:
                        logger.warning(f"Could not delete old album {old_id}: {remaining} tracks still reference it")

                    if migrated_tracks > 0:
                        logger.info(f"   Migrated {migrated_tracks} tracks to new album ID")
                else:
                    # Genuinely new album — insert fresh record
                    cursor.execute("""
                        INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration, server_source)
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
                    """, (album_id, artist_id, title, year, thumb_url, genres_json, track_count, duration, server_source))

            conn.commit()
            return True
            
        except Exception as e:
            logger.error(f"Error inserting/updating {server_source} album {getattr(album_obj, 'title', 'Unknown')}: {e}")
            return False
    
    def get_album_display_meta(self, album_id) -> Optional[Dict[str, Any]]:
        """Return ``{album_title, artist_id, artist_name}`` for an album row.

        Used by the reorganize queue enqueue endpoint to capture display
        strings at submission time so the status panel can render
        without a DB lookup per poll. Returns None when the album row
        does not exist; lets DB errors bubble up so callers can surface
        a real failure instead of swallowing it as "album not found".
        """
        with self._get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute(
                """
                SELECT al.title AS album_title,
                       ar.id    AS artist_id,
                       ar.name  AS artist_name
                FROM albums al
                JOIN artists ar ON al.artist_id = ar.id
                WHERE al.id = ?
                """,
                (str(album_id),),
            )
            row = cursor.fetchone()
            if not row:
                return None
            return {
                'album_title': row['album_title'] or 'Unknown Album',
                'artist_id': str(row['artist_id']) if row['artist_id'] is not None else None,
                'artist_name': row['artist_name'] or 'Unknown Artist',
            }

    def get_artist_albums_for_reorganize(self, artist_id) -> List[Dict[str, Any]]:
        """Return ``[{album_id, album_title, artist_id, artist_name}, ...]``
        for every album owned by ``artist_id``, ordered by year then
        title. Used by the bulk Reorganize-All endpoint to pull the
        full tracklist server-side instead of trusting whatever the
        frontend cached. Returns an empty list when the artist has no
        albums; lets DB errors bubble so a real failure surfaces as a
        500 rather than masquerading as "no albums found".
        """
        with self._get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute(
                """
                SELECT al.id    AS album_id,
                       al.title AS album_title,
                       ar.id    AS artist_id,
                       ar.name  AS artist_name
                FROM albums al
                JOIN artists ar ON al.artist_id = ar.id
                WHERE ar.id = ?
                ORDER BY al.year ASC, al.title ASC
                """,
                (str(artist_id),),
            )
            return [dict(r) for r in cursor.fetchall()]

    def get_albums_by_artist(self, artist_id: int) -> List[DatabaseAlbum]:
        """Get all albums by artist ID"""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            
            cursor.execute("SELECT * FROM albums WHERE artist_id = ? ORDER BY year, title", (artist_id,))
            rows = cursor.fetchall()
            
            albums = []
            for row in rows:
                genres = json.loads(row['genres']) if row['genres'] else None
                albums.append(DatabaseAlbum(
                    id=row['id'],
                    artist_id=row['artist_id'],
                    title=row['title'],
                    year=row['year'],
                    thumb_url=row['thumb_url'],
                    genres=genres,
                    track_count=row['track_count'],
                    duration=row['duration'],
                    created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
                    updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None
                ))
            
            return albums
            
        except Exception as e:
            logger.error(f"Error getting albums for artist {artist_id}: {e}")
            return []
    
    # Track operations
    def insert_or_update_track(self, plex_track, album_id: int, artist_id: int) -> bool:
        """Insert or update track from Plex track object - DEPRECATED: Use insert_or_update_media_track instead"""
        return self.insert_or_update_media_track(plex_track, album_id, artist_id, server_source='plex')
    
    def insert_or_update_media_track(self, track_obj, album_id: str, artist_id: str, server_source: str = 'plex') -> bool:
        """Insert or update track from media server track object (Plex or Jellyfin) with retry logic"""
        max_retries = 3
        retry_count = 0
        
        while retry_count < max_retries:
            try:
                conn = self._get_connection()
                cursor = conn.cursor()
                
                # Set shorter timeout to prevent long locks
                cursor.execute("PRAGMA busy_timeout = 10000")  # 10 second timeout
                
                # Convert track ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
                track_id = str(track_obj.ratingKey)
                title = track_obj.title
                track_number = getattr(track_obj, 'trackNumber', None)
                duration = getattr(track_obj, 'duration', None)
                
                # Get file path and media info (Plex-specific, Jellyfin may not have these)
                file_path = None
                bitrate = None
                file_size = None
                if hasattr(track_obj, 'media') and track_obj.media:
                    media = track_obj.media[0] if track_obj.media else None
                    if media:
                        if hasattr(media, 'parts') and media.parts:
                            part = media.parts[0]
                            file_path = getattr(part, 'file', None)
                            # Plex's MediaPart exposes the file size in bytes
                            # via plexapi — pull it for the Library Disk
                            # Usage card on Stats. None when the server
                            # didn't report a size.
                            _plex_size = getattr(part, 'size', None)
                            if isinstance(_plex_size, int) and _plex_size > 0:
                                file_size = _plex_size
                        bitrate = getattr(media, 'bitrate', None)

                # Fallback for Navidrome/Subsonic tracks
                if file_path is None and hasattr(track_obj, 'path') and track_obj.path:
                    file_path = track_obj.path
                if bitrate is None and hasattr(track_obj, 'bitRate') and track_obj.bitRate:
                    bitrate = track_obj.bitRate
                if file_path is None and hasattr(track_obj, 'suffix') and track_obj.suffix:
                    file_path = f"{track_obj.title}.{track_obj.suffix}"
                # File size: Jellyfin / Navidrome / SoulSync-standalone
                # all set track_obj.file_size on their wrapper class.
                # Plex came in via the media.parts[0].size path above —
                # don't clobber that.
                if file_size is None and hasattr(track_obj, 'file_size'):
                    _wrapper_size = getattr(track_obj, 'file_size', None)
                    if isinstance(_wrapper_size, int) and _wrapper_size > 0:
                        file_size = _wrapper_size

                # 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: store ALL ArtistItems, not just [0]. A track
                # like "Super Single" by Artist1 feat. Artist2 has both names in
                # ArtistItems; if we kept only the first, completion checks for
                # Artist2's discography (where the same track also appears as a
                # single) would never find this row in the library. Joining with
                # "; " matches Jellyfin's own UI convention and lets the search
                # path treat each name as a separate artist credit.
                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_names = [
                            a.get('Name', '') for a in artist_items if a.get('Name')
                        ]
                        jf_track_artist = '; '.join(jf_track_artist_names)
                        album_artists = raw.get('AlbumArtists', [])
                        jf_album_artist = album_artists[0].get('Name', '') if album_artists else ''
                        # Store when the track has multiple artists OR when the
                        # single-artist credit differs from the album artist.
                        if jf_track_artist and (
                            len(jf_track_artist_names) > 1
                            or 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 as e:
                        logger.debug("Failed to load album artist for track_artist comparison: %s", e)

                # 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,
                # INSERT only for genuinely new tracks
                cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,))
                is_new_track = cursor.fetchone() is None

                if is_new_track:
                    cursor.execute("""
                        INSERT INTO tracks
                        (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, file_size, server_source, track_artist, musicbrainz_recording_id, updated_at)
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
                    """, (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid))
                else:
                    # Update server-provided fields only — preserves spotify_track_id, deezer_id,
                    # isrc, bpm, and all other enrichment data. file_size uses
                    # COALESCE(?, file_size) so a NULL from the server (e.g.
                    # Jellyfin sometimes omits Size on first sync) doesn't wipe
                    # an existing value.
                    cursor.execute("""
                        UPDATE tracks
                        SET album_id = ?, artist_id = ?, title = ?, track_number = ?,
                            duration = ?, file_path = ?, bitrate = ?,
                            file_size = COALESCE(?, file_size),
                            server_source = ?,
                            track_artist = COALESCE(?, track_artist),
                            musicbrainz_recording_id = COALESCE(?, musicbrainz_recording_id),
                            updated_at = CURRENT_TIMESTAMP
                        WHERE id = ?
                    """, (album_id, artist_id, title, track_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid, track_id))

                conn.commit()

                # Backfill external metadata-source IDs from track_downloads
                # provenance. SoulSync collected them at download time but the
                # media-server scan can't see them — without this hook,
                # tracks.spotify_track_id / itunes_track_id / etc. stay empty
                # until the async enrichment workers eventually catch up
                # (hours later), during which window the watchlist scanner
                # treats freshly downloaded files as missing and re-downloads
                # them. Idempotent COALESCE on each column preserves any value
                # the enrichment worker already wrote.
                try:
                    self.backfill_track_external_ids_from_provenance(track_id, file_path)
                except Exception as backfill_err:
                    logger.debug(f"Provenance ID backfill skipped for track {track_id}: {backfill_err}")

                # Log new imports to library history
                if is_new_track:
                    try:
                        cursor.execute("SELECT name FROM artists WHERE id = ?", (artist_id,))
                        artist_row = cursor.fetchone()
                        cursor.execute("SELECT title, thumb_url FROM albums WHERE id = ?", (album_id,))
                        album_row = cursor.fetchone()
                        self.add_library_history_entry(
                            event_type='import',
                            title=title,
                            artist_name=artist_row[0] if artist_row else None,
                            album_name=album_row[0] if album_row else None,
                            server_source=server_source,
                            file_path=file_path,
                            thumb_url=album_row[1] if album_row and len(album_row) > 1 else None
                        )
                    except Exception as e:
                        logger.debug("history logging: %s", e)

                return True
                
            except Exception as e:
                error_text = str(e).lower()
                if (
                    'file_size' in error_text
                    and ('no such column' in error_text or 'no column named' in error_text)
                    and retry_count < max_retries - 1
                ):
                    try:
                        repair_conn = conn if 'conn' in locals() else self._get_connection()
                        repair_cursor = repair_conn.cursor()
                        self._ensure_core_media_schema_columns(repair_cursor)
                        repair_conn.commit()
                        if repair_conn is not conn:
                            repair_conn.close()
                        retry_count += 1
                        logger.info("Repaired missing file_size column while importing media track; retrying")
                        continue
                    except Exception as schema_error:
                        logger.error("Failed to repair tracks.file_size during track import: %s", schema_error)

                retry_count += 1
                if "database is locked" in str(e).lower() and retry_count < max_retries:
                    logger.warning(f"Database locked on track '{getattr(track_obj, 'title', 'Unknown')}', retrying {retry_count}/{max_retries}...")
                    time.sleep(0.1 * retry_count)  # Exponential backoff
                    continue
                else:
                    logger.error(f"Error inserting/updating {server_source} track {getattr(track_obj, 'title', 'Unknown')}: {e}")
                    return False
        
        return False
    
    def track_exists(self, track_id) -> bool:
        """Check if a track exists in the database by ID (supports both int and string IDs)"""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            
            # Convert to string to handle both Plex integers and Jellyfin GUIDs
            track_id_str = str(track_id)
            cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id_str,))
            result = cursor.fetchone()
            
            return result is not None
            
        except Exception as e:
            logger.error(f"Error checking if track {track_id} exists: {e}")
            return False
    
    def track_exists_by_server(self, track_id, server_source: str) -> bool:
        """Check if a track exists in the database by ID and server source"""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            
            # Convert to string to handle both Plex integers and Jellyfin GUIDs
            track_id_str = str(track_id)
            cursor.execute("SELECT 1 FROM tracks WHERE id = ? AND server_source = ? LIMIT 1", (track_id_str, server_source))
            result = cursor.fetchone()
            
            return result is not None
            
        except Exception as e:
            logger.error(f"Error checking if track {track_id} exists for server {server_source}: {e}")
            return False
    
    def get_track_by_id(self, track_id) -> Optional[DatabaseTrackWithMetadata]:
        """Get a track with artist and album names by ID (supports both int and string IDs)"""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            
            # Convert to string to handle both Plex integers and Jellyfin GUIDs
            track_id_str = str(track_id)
            cursor.execute("""
                SELECT t.id, t.album_id, t.artist_id, t.title, t.track_number, 
                       t.duration, t.created_at, t.updated_at,
                       a.name as artist_name, al.title as album_title
                FROM tracks t
                JOIN artists a ON t.artist_id = a.id
                JOIN albums al ON t.album_id = al.id
                WHERE t.id = ?
            """, (track_id_str,))
            
            row = cursor.fetchone()
            if row:
                return DatabaseTrackWithMetadata(
                    id=row['id'],
                    album_id=row['album_id'],
                    artist_id=row['artist_id'],
                    title=row['title'],
                    artist_name=row['artist_name'],
                    album_title=row['album_title'],
                    track_number=row['track_number'],
                    duration=row['duration'],
                    created_at=row['created_at'],
                    updated_at=row['updated_at']
                )
            return None
            
        except Exception as e:
            logger.error(f"Error getting track {track_id}: {e}")
            return None
    
    def get_tracks_by_album(self, album_id: int) -> List[DatabaseTrack]:
        """Get all tracks by album ID"""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            
            cursor.execute("SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number, title", (album_id,))
            rows = cursor.fetchall()
            
            tracks = []
            for row in rows:
                tracks.append(DatabaseTrack(
                    id=row['id'],
                    album_id=row['album_id'],
                    artist_id=row['artist_id'],
                    title=row['title'],
                    track_number=row['track_number'],
                    duration=row['duration'],
                    file_path=row['file_path'],
                    bitrate=row['bitrate'],
                    created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
                    updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None
                ))
            
            return tracks
            
        except Exception as e:
            logger.error(f"Error getting tracks for album {album_id}: {e}")
            return []
    
    def search_artists(self, query: str, limit: int = 50, server_source: str = None) -> List[DatabaseArtist]:
        """Search artists by name, optionally filtered by server source.
        Uses diacritic-insensitive matching so 'Tiesto' finds 'Tiësto'."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            norm_query = f"%{self._normalize_for_comparison(query)}%"

            if server_source:
                cursor.execute("""
                    SELECT * FROM artists
                    WHERE unidecode_lower(name) LIKE ? AND server_source = ?
                    ORDER BY name
                    LIMIT ?
                """, (norm_query, server_source, limit))
            else:
                cursor.execute("""
                    SELECT * FROM artists
                    WHERE unidecode_lower(name) LIKE ?
                    ORDER BY name
                    LIMIT ?
                """, (norm_query, limit))
            
            rows = cursor.fetchall()
            
            artists = []
            for row in rows:
                genres = json.loads(row['genres']) if row['genres'] else None
                artists.append(DatabaseArtist(
                    id=row['id'],
                    name=row['name'],
                    thumb_url=row['thumb_url'],
                    genres=genres,
                    summary=row['summary'],
                    created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
                    updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None
                ))
            
            return artists
            
        except Exception as e:
            logger.error(f"Error searching artists with query '{query}': {e}")
            return []
    
    def search_tracks(self, title: str = "", artist: str = "", limit: int = 50, server_source: str = None) -> List[DatabaseTrack]:
        """Search tracks by title and/or artist name with Unicode-aware fuzzy matching"""
        try:
            if not title and not artist:
                return []
            
            conn = self._get_connection()
            cursor = conn.cursor()
            
            # STRATEGY 1: Try basic SQL LIKE search first (fastest)
            basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source)
            
            if basic_results:
                logger.debug(f"Basic search found {len(basic_results)} results")
                return basic_results

            # 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)
            if fuzzy_results:
                logger.debug(f"Fuzzy fallback search found {len(fuzzy_results)} results")
            
            return fuzzy_results
            
        except Exception as e:
            logger.error(f"Error searching tracks with title='{title}', artist='{artist}': {e}")
            return []

    def api_search_tracks(self, title: str = "", artist: str = "", limit: int = 50,
                          server_source: Optional[str] = None) -> List[Dict[str, Any]]:
        """Search tracks and return full dict rows (all track columns plus artist_name,
        album_title, album_thumb_url). Avoids the double-query pattern of calling
        search_tracks() followed by api_get_tracks_by_ids().
        """
        try:
            if not title and not artist:
                return []

            conn = self._get_connection()
            cursor = conn.cursor()

            basic_rows = self._search_tracks_basic_rows(cursor, title, artist, limit, server_source)
            if basic_rows:
                return [dict(r) for r in basic_rows]

            fuzzy_rows = self._search_tracks_fuzzy_rows(cursor, title, artist, limit, server_source)
            return [dict(r) for r in fuzzy_rows]
        except Exception as e:
            logger.error(f"API: Error searching tracks with title='{title}', artist='{artist}': {e}")
            return []
    
    def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]:
        """Basic SQL LIKE search - fastest method"""
        rows = self._search_tracks_basic_rows(cursor, title, artist, limit, server_source)
        return self._rows_to_tracks(rows)

    def _search_tracks_basic_rows(self, cursor, title: str, artist: str, limit: int,
                                  server_source: Optional[str] = None):
        """Basic SQL LIKE search returning raw rows (shared by DatabaseTrack and dict-returning callers)."""
        where_conditions = []
        params = []

        if title:
            where_conditions.append("unidecode_lower(tracks.title) LIKE ?")
            params.append(f"%{self._normalize_for_comparison(title)}%")

        if artist:
            norm_artist = 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
        if server_source:
            where_conditions.append("tracks.server_source = ?")
            params.append(server_source)

        if not where_conditions:
            return []

        where_clause = " AND ".join(where_conditions)
        params.append(limit)

        cursor.execute(f"""
            SELECT tracks.*, artists.name as artist_name, albums.title as album_title, albums.thumb_url as album_thumb_url
            FROM tracks
            JOIN artists ON tracks.artist_id = artists.id
            JOIN albums ON tracks.album_id = albums.id
            WHERE {where_clause}
            ORDER BY tracks.title, artists.name
            LIMIT ?
        """, params)

        return cursor.fetchall()
    
    def _search_tracks_fuzzy_fallback(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]:
        """Broadest fuzzy search - partial word matching"""
        rows = self._search_tracks_fuzzy_rows(cursor, title, artist, limit, server_source)
        return self._rows_to_tracks(rows)

    def _search_tracks_fuzzy_rows(self, cursor, title: str, artist: str, limit: int,
                                  server_source: Optional[str] = None):
        """Broadest fuzzy search returning raw rows (shared by DatabaseTrack and dict-returning callers)."""
        # Get broader results by searching for individual words
        search_terms = []
        if title:
            title_words = [w.strip() for w in self._normalize_for_comparison(title).split() if len(w.strip()) >= 3]
            search_terms.extend(title_words)

        if artist:
            artist_words = [w.strip() for w in self._normalize_for_comparison(artist).split() if len(w.strip()) >= 3]
            search_terms.extend(artist_words)

        if not search_terms:
            return []

        like_conditions = []
        params = []

        for term in search_terms[:5]:
            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}%", f"%{term}%"])

        if not like_conditions:
            return []

        where_parts = [f"({' OR '.join(like_conditions)})"]
        if server_source:
            where_parts.append("tracks.server_source = ?")
            params.append(server_source)

        where_clause = " AND ".join(where_parts)
        params.append(limit * 3)

        cursor.execute(f"""
            SELECT tracks.*, artists.name as artist_name, albums.title as album_title, albums.thumb_url as album_thumb_url
            FROM tracks
            JOIN artists ON tracks.artist_id = artists.id
            JOIN albums ON tracks.album_id = albums.id
            WHERE {where_clause}
            ORDER BY tracks.title, artists.name
            LIMIT ?
        """, params)

        rows = cursor.fetchall()

        # Score and filter results
        scored_results = []
        for row in rows:
            score = 0
            db_title_lower = self._normalize_for_comparison(row['title'])
            db_artist_lower = self._normalize_for_comparison(row['artist_name'])

            for term in search_terms:
                if term in db_title_lower or term in db_artist_lower:
                    score += 1

            if score > 0:
                scored_results.append((score, row))

        scored_results.sort(key=lambda x: x[0], reverse=True)
        return [row for score, row in scored_results[:limit]]
    
    def _rows_to_tracks(self, rows) -> List[DatabaseTrack]:
        """Convert database rows to DatabaseTrack objects"""
        tracks = []
        for row in rows:
            track = DatabaseTrack(
                id=row['id'],
                album_id=row['album_id'],
                artist_id=row['artist_id'],
                title=row['title'],
                track_number=row['track_number'],
                duration=row['duration'],
                file_path=row['file_path'],
                bitrate=row['bitrate'],
                created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
                updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None
            )
            # Add artist and album info for compatibility with Plex responses
            track.artist_name = row['artist_name']
            track.album_title = row['album_title']
            track.album_thumb_url = row['album_thumb_url'] if 'album_thumb_url' in row.keys() else ''
            track.server_source = row['server_source'] if 'server_source' in row.keys() else ''
            # Per-track artist (from ID3 ARTIST tag) for compilations/soundtracks where
            # the track artist differs from the album artist. Used by
            # _calculate_track_confidence so soundtrack tracks credited to the song's
            # actual performer match correctly when the album sits under a different
            # primary artist (Plex's track.originalTitle, Jellyfin's ArtistItems[0]).
            track.track_artist = row['track_artist'] if 'track_artist' in row.keys() else None
            tracks.append(track)
        return tracks
    
    def search_albums(self, title: str = "", artist: str = "", limit: int = 50, server_source: Optional[str] = None) -> List[DatabaseAlbum]:
        """Search albums by title and/or artist name with fuzzy matching"""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            
            # Build dynamic query based on provided parameters  
            where_conditions = []
            params = []
            
            if title:
                where_conditions.append("unidecode_lower(albums.title) LIKE ?")
                params.append(f"%{self._normalize_for_comparison(title)}%")

            if artist:
                where_conditions.append("unidecode_lower(artists.name) LIKE ?")
                params.append(f"%{self._normalize_for_comparison(artist)}%")

            if server_source:
                where_conditions.append("albums.server_source = ?")
                params.append(server_source)

            if not where_conditions:
                # If no search criteria, return empty list
                return []

            where_clause = " AND ".join(where_conditions)
            params.append(limit)

            cursor.execute(f"""
                SELECT albums.*, artists.name as artist_name
                FROM albums
                JOIN artists ON albums.artist_id = artists.id
                WHERE {where_clause}
                ORDER BY albums.title, artists.name
                LIMIT ?
            """, params)
            
            rows = cursor.fetchall()
            
            albums = []
            for row in rows:
                genres = json.loads(row['genres']) if row['genres'] else None
                album = DatabaseAlbum(
                    id=row['id'],
                    artist_id=row['artist_id'],
                    title=row['title'],
                    year=row['year'],
                    thumb_url=row['thumb_url'],
                    genres=genres,
                    track_count=row['track_count'],
                    duration=row['duration'],
                    created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
                    updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None
                )
                # Add artist info for compatibility with Plex responses
                album.artist_name = row['artist_name']
                albums.append(album)
            
            return albums
            
        except Exception as e:
            logger.error(f"Error searching albums with title='{title}', artist='{artist}': {e}")
            return []
        


    def _get_artist_variations(self, artist_name: str) -> List[str]:
            """Returns a list of known variations for an artist's name."""
            variations = [artist_name]
            name_lower = artist_name.lower()

            # Add diacritic-normalized variation (fixes #101)
            # This allows "Subcarpaţi" to match "Subcarpati" in SQL LIKE queries
            normalized_name = self._normalize_for_comparison(artist_name)
            # Only add if it's different from original (avoid duplicates)
            if normalized_name != artist_name.lower():
                # Add with original casing style if possible
                variations.append(normalized_name.title())
                variations.append(normalized_name)

            # Add more aliases here in the future
            if "korn" in name_lower:
                if "KoЯn" not in variations:
                    variations.append("KoЯn")
                if "Korn" not in variations:
                    variations.append("Korn")

            # Return unique variations
            return list(set(variations))

    
    def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8, server_source: str = None, album: str = None, candidate_tracks: Optional[List[DatabaseTrack]] = None) -> Tuple[Optional[DatabaseTrack], float]:
        """
        Check if a track exists in the database with enhanced fuzzy matching and confidence scoring.

        Args:
            album: Optional album name — enables album-aware matching for multi-artist albums
            candidate_tracks: Optional pre-fetched list of tracks to match against in-memory,
                              skipping the per-variation SQL loop. Intended for callers iterating
                              a discography that already fetched the artist's tracks once via
                              get_candidate_tracks_for_albums. None preserves original behavior.

        Returns (track, confidence) tuple where confidence is 0.0-1.0
        """
        try:
            best_match = None
            best_confidence = 0.0

            if candidate_tracks is not None:
                # BATCHED PATH — score every pre-fetched track in-memory.
                # _calculate_track_confidence already handles title normalization,
                # so no need for the per-variation SQL widening.
                logger.debug(f"Enhanced track matching for '{title}' by '{artist}': batched against {len(candidate_tracks)} candidates")
                for track in candidate_tracks:
                    confidence = self._calculate_track_confidence(title, artist, track)
                    if confidence > best_confidence:
                        best_confidence = confidence
                        best_match = track
            else:
                # LEGACY PATH — generate title variations and fire SQL per variation.
                title_variations = self._generate_track_title_variations(title)

                logger.debug(f"Enhanced track matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
                for i, var in enumerate(title_variations):
                    logger.debug(f"  {i+1}. '{var}'")

                # Try each title variation
                for title_variation in title_variations:
                    # Search for potential matches with this variation
                    potential_matches = []
                    artist_variations = self._get_artist_variations(artist)
                    for artist_variation in artist_variations:
                        potential_matches.extend(self.search_tracks(title=title_variation, artist=artist_variation, limit=20, server_source=server_source))

                    if not potential_matches:
                        continue

                    logger.debug(f"Found {len(potential_matches)} tracks for variation '{title_variation}'")

                    # Score each potential match
                    for track in potential_matches:
                        confidence = self._calculate_track_confidence(title, artist, track)
                        logger.debug(f"  '{track.title}' confidence: {confidence:.3f}")

                        if confidence > best_confidence:
                            best_confidence = confidence
                            best_match = track

            # Return match only if it meets threshold
            if best_match and best_confidence >= confidence_threshold:
                logger.debug(f"Enhanced track match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
                return best_match, best_confidence

            # 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
            if album and best_confidence < confidence_threshold:
                logger.debug(f"Artist-specific search failed, trying album-aware fallback: '{title}' on '{album}'")
                try:
                    album_candidates = self.search_albums(title=album, artist="", limit=10, server_source=server_source)
                    for album_candidate in album_candidates:
                        album_title_sim = max(
                            self._string_similarity(self._normalize_for_comparison(album), self._normalize_for_comparison(album_candidate.title)),
                            self._string_similarity(self._clean_album_title_for_comparison(album), self._clean_album_title_for_comparison(album_candidate.title))
                        )
                        if album_title_sim < 0.8:
                            continue

                        conn = self._get_connection()
                        cursor = conn.cursor()
                        source_filter = "AND t.server_source = ?" if server_source else ""
                        params = [album_candidate.id] + ([server_source] if server_source else [])
                        cursor.execute(f"""
                            SELECT t.*, a.name as artist_name, al.title as album_title
                            FROM tracks t
                            JOIN artists a ON a.id = t.artist_id
                            JOIN albums al ON al.id = t.album_id
                            WHERE t.album_id = ? {source_filter}
                        """, params)

                        for row in cursor.fetchall():
                            # DatabaseTrack is a strict dataclass — only the declared
                            # fields go in __init__; the joined artist/album/server
                            # values are attached afterwards just like _rows_to_tracks
                            # does. Building it the kwarg-soup way used to raise
                            # TypeError on every fallback row, silently swallowed by
                            # the outer except, so this path never matched anything.
                            db_track = DatabaseTrack(
                                id=row['id'], album_id=row['album_id'], artist_id=row['artist_id'],
                                title=row['title'], track_number=row['track_number'],
                                duration=row['duration'], file_path=row['file_path'],
                                bitrate=row['bitrate'],
                            )
                            db_track.artist_name = row['artist_name']
                            db_track.album_title = row['album_title']
                            db_track.server_source = row['server_source']
                            db_track.track_artist = row['track_artist'] if 'track_artist' in row.keys() else None
                            title_sim = max(
                                self._string_similarity(self._normalize_for_comparison(title), self._normalize_for_comparison(db_track.title)),
                                self._string_similarity(self._clean_track_title_for_comparison(title), self._clean_track_title_for_comparison(db_track.title))
                            )
                            if title_sim > best_confidence and title_sim >= 0.7:
                                best_confidence = title_sim
                                best_match = db_track

                        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})")
                            return best_match, best_confidence
                except Exception as 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})")
            return None, best_confidence
            
        except Exception as e:
            logger.error(f"Error checking track existence for '{title}' by '{artist}': {e}")
            return None, 0.0
    
    def check_album_exists(self, title: str, artist: str, confidence_threshold: float = 0.8) -> Tuple[Optional[DatabaseAlbum], float]:
        """
        Check if an album exists in the database with fuzzy matching and confidence scoring.
        Returns (album, confidence) tuple where confidence is 0.0-1.0
        """
        try:
            # Search for potential matches
            potential_matches = self.search_albums(title=title, artist=artist, limit=20)
            
            if not potential_matches:
                return None, 0.0
            
            # Simple confidence scoring based on string similarity
            def calculate_confidence(db_album: DatabaseAlbum) -> float:
                title_similarity = self._string_similarity(title.lower().strip(), db_album.title.lower().strip())
                artist_similarity = self._string_similarity(artist.lower().strip(), db_album.artist_name.lower().strip())
                
                # Weight title and artist equally for albums
                return (title_similarity * 0.5) + (artist_similarity * 0.5)
            
            # Find best match
            best_match = None
            best_confidence = 0.0
            
            for album in potential_matches:
                confidence = calculate_confidence(album)
                if confidence > best_confidence:
                    best_confidence = confidence
                    best_match = album
            
            # Return match only if it meets threshold
            if best_confidence >= confidence_threshold:
                return best_match, best_confidence
            else:
                return None, best_confidence
            
        except Exception as e:
            logger.error(f"Error checking album existence for '{title}' by '{artist}': {e}")
            return None, 0.0
    
    def _string_similarity(self, s1: str, s2: str) -> float:
        """
        Calculate string similarity using enhanced matching engine logic if available,
        otherwise falls back to Levenshtein distance.
        Returns value between 0.0 (no similarity) and 1.0 (identical)
        """
        if s1 == s2:
            return 1.0

        if not s1 or not s2:
            return 0.0

        # Censored title detection: Apple Music returns "B*****t" for "Bullshit"
        # Asterisks replace middle characters — word count matches, non-censored words match,
        # censored words share first char and non-asterisk trailing chars
        if '*' in s1 or '*' in s2:
            censored, uncensored = (s1, s2) if '*' in s1 else (s2, s1)
            c_words = censored.lower().split()
            u_words = uncensored.lower().split()
            if len(c_words) == len(u_words):
                all_match = True
                for cw, uw in zip(c_words, u_words, strict=False):
                    if '*' in cw:
                        # Strip asterisks to get the visible prefix/suffix
                        # "b*****t" → prefix "b", suffix "t"
                        # "f**k" → prefix "f", suffix "k"
                        prefix = cw.split('*')[0]
                        suffix = cw.rstrip('*').split('*')[-1] if not cw.endswith('*') else ''
                        if not uw.startswith(prefix):
                            all_match = False
                            break
                        if suffix and not uw.endswith(suffix):
                            all_match = False
                            break
                    else:
                        if cw != uw:
                            all_match = False
                            break
                if all_match:
                    return 1.0

        # Use enhanced similarity from matching engine if available
        if _matching_engine:
            return _matching_engine.similarity_score(s1, s2)
        
        # Simple Levenshtein distance implementation
        len1, len2 = len(s1), len(s2)
        if len1 < len2:
            s1, s2 = s2, s1
            len1, len2 = len2, len1
        
        if len2 == 0:
            return 0.0
        
        # Create matrix
        previous_row = list(range(len2 + 1))
        for i, c1 in enumerate(s1):
            current_row = [i + 1]
            for j, c2 in enumerate(s2):
                insertions = previous_row[j + 1] + 1
                deletions = current_row[j] + 1
                substitutions = previous_row[j] + (c1 != c2)
                current_row.append(min(insertions, deletions, substitutions))
            previous_row = current_row
        
        max_len = max(len1, len2)
        distance = previous_row[-1]
        similarity = (max_len - distance) / max_len
        
        return max(0.0, similarity)
    
    def check_album_completeness(self, album_id: int, expected_track_count: Optional[int] = None) -> Tuple[int, int, bool, List[str]]:
        """
        Check if we have all tracks for an album.
        Merges counts across split album entries (same title+year+artist) so that
        albums split by the media server (e.g. Navidrome) are treated as one.
        Returns (owned_tracks, expected_tracks, is_complete, formats)
        where formats is a list of distinct format strings like ["FLAC"] or ["FLAC", "MP3-320"]
        """
        try:
            conn = self._get_connection()
            cursor = conn.cursor()

            # Look up this album's title, year, and artist to find all sibling entries
            cursor.execute("SELECT title, year, artist_id FROM albums WHERE id = ?", (album_id,))
            album_info = cursor.fetchone()

            if not album_info:
                return 0, 0, False, []

            # Find all album IDs that share the same title, year, and artist
            # This merges split albums (e.g. Navidrome splitting one album into multiple entries)
            cursor.execute("""
                SELECT id FROM albums
                WHERE title = ? AND artist_id = ? AND (year IS ? OR (year IS NULL AND ? IS NULL))
            """, (album_info['title'], album_info['artist_id'], album_info['year'], album_info['year']))
            sibling_ids = [row['id'] for row in cursor.fetchall()]

            # Get actual track count across all sibling album entries
            # Count DISTINCT titles to deduplicate across split/duplicate album entries
            # (e.g., 3 "GNX" albums with 12+1+2 tracks = 15 rows but only 12 unique songs)
            placeholders = ','.join('?' for _ in sibling_ids)
            cursor.execute(f"""
                SELECT COUNT(*) FROM (
                    SELECT DISTINCT LOWER(title), track_number FROM tracks
                    WHERE album_id IN ({placeholders}) AND file_path IS NOT NULL AND file_path != ''
                )
            """, sibling_ids)
            owned_tracks = cursor.fetchone()[0]

            # Get the max track_count from sibling albums (not SUM — avoids inflating from duplicates)
            cursor.execute(f"SELECT MAX(track_count) FROM albums WHERE id IN ({placeholders})", sibling_ids)
            result = cursor.fetchone()
            stored_track_count = result[0] if result and result[0] else 0

            # Use provided expected count if available, otherwise use stored count.
            # However, if the album is complete by its own stored metadata, prefer the stored
            # count so edition differences don't make a complete album appear incomplete.
            # e.g. user has standard edition (12 tracks, all present) but Spotify returns
            # deluxe edition count (20) — should show as complete, not 12/20.
            if (expected_track_count is not None and stored_track_count > 0
                    and owned_tracks >= stored_track_count
                    and stored_track_count >= expected_track_count * 0.6):
                # Album is complete by its own metadata — standard vs deluxe edition difference
                expected_tracks = stored_track_count
            elif expected_track_count is not None:
                expected_tracks = expected_track_count
            else:
                expected_tracks = stored_track_count

            # Determine completeness with refined thresholds
            if expected_tracks and expected_tracks > 0:
                # Exact match — complete only when owned == expected
                is_complete = owned_tracks >= expected_tracks
            else:
                # No expected count known — complete if we have any tracks
                is_complete = owned_tracks > 0

            # Get distinct format strings for owned tracks
            formats = self._get_album_formats(cursor, sibling_ids)

            return owned_tracks, expected_tracks or 0, is_complete, formats

        except Exception as e:
            logger.error(f"Error checking album completeness for album_id {album_id}: {e}")
            return 0, 0, False, []

    def _get_album_formats(self, cursor, sibling_ids: list) -> List[str]:
        """Get distinct format strings for tracks in the given album IDs."""
        try:
            placeholders = ','.join('?' for _ in sibling_ids)
            cursor.execute(f"""
                SELECT file_path, bitrate FROM tracks
                WHERE album_id IN ({placeholders}) AND file_path IS NOT NULL
            """, sibling_ids)

            format_set = set()
            for row in cursor.fetchall():
                ext = os.path.splitext(row['file_path'] or '')[1].lstrip('.').upper()
                if not ext:
                    continue
                if ext == 'MP3' and row['bitrate']:
                    format_set.add(f"MP3-{row['bitrate']}")
                elif ext == 'MP3':
                    format_set.add('MP3')
                else:
                    format_set.add(ext)
            return sorted(format_set)
        except Exception as e:
            logger.error(f"Error getting album formats: {e}")
            return []
    
    def get_candidate_albums_for_artist(self, artist: str, server_source: Optional[str] = None, limit: int = 200) -> List[DatabaseAlbum]:
        """
        Fetch every library album for an artist, merged across artist-name variations
        and deduplicated by album ID. Intended to be called once per artist page load
        so subsequent per-album matching can run in-memory against this list without
        re-hitting SQL for each discography item.
        """
        candidates: List[DatabaseAlbum] = []
        try:
            seen_ids = set()
            for artist_var in self._get_artist_variations(artist):
                found = self.search_albums(title="", artist=artist_var, limit=limit, server_source=server_source)
                for album in found:
                    if album.id not in seen_ids:
                        candidates.append(album)
                        seen_ids.add(album.id)
            return candidates
        except Exception as e:
            logger.error(f"Error fetching candidate albums for artist '{artist}': {e}")
            return candidates

    def get_candidate_tracks_for_albums(self, album_ids: List) -> List[DatabaseTrack]:
        """
        Fetch every track belonging to the given set of album IDs in a single query.
        Used for batched track-level completion checks (true singles on discography).
        Returns DatabaseTrack objects with artist_name/album_title/server_source attrs
        attached, matching the shape produced by search_tracks.
        """
        if not album_ids:
            return []
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            placeholders = ','.join('?' for _ in album_ids)
            cursor.execute(f"""
                SELECT t.*, a.name as artist_name, al.title as album_title, al.thumb_url as album_thumb_url
                FROM tracks t
                JOIN artists a ON a.id = t.artist_id
                JOIN albums al ON al.id = t.album_id
                WHERE t.album_id IN ({placeholders})
            """, list(album_ids))
            rows = cursor.fetchall()
            tracks: List[DatabaseTrack] = []
            for row in rows:
                track = DatabaseTrack(
                    id=row['id'],
                    album_id=row['album_id'],
                    artist_id=row['artist_id'],
                    title=row['title'],
                    track_number=row['track_number'],
                    duration=row['duration'],
                    file_path=row['file_path'],
                    bitrate=row['bitrate'],
                )
                # Attach joined fields the same way search_tracks does
                track.artist_name = row['artist_name']
                track.album_title = row['album_title']
                track.album_thumb_url = row['album_thumb_url'] if 'album_thumb_url' in row.keys() else ''
                track.server_source = row['server_source'] if 'server_source' in row.keys() else ''
                tracks.append(track)
            return tracks
        except Exception as e:
            logger.error(f"Error fetching candidate tracks for {len(album_ids)} album IDs: {e}")
            return []

    def check_album_exists_with_completeness(self, title: str, artist: str, expected_track_count: Optional[int] = None, confidence_threshold: float = 0.8, server_source: Optional[str] = None, candidate_albums: Optional[List[DatabaseAlbum]] = None, strict_discography_match: bool = False) -> Tuple[Optional[DatabaseAlbum], float, int, int, bool, List[str]]:
        """
        Check if an album exists in the database with completeness information.
        Enhanced to handle edition matching (standard <-> deluxe variants).
        Returns (album, confidence, owned_tracks, expected_tracks, is_complete, formats)

        When `candidate_albums` is provided (via get_candidate_albums_for_artist),
        the matcher runs in-memory against that list instead of firing per-album
        SQL searches. `None` preserves the original search-every-time behavior.
        """
        try:
            # Try enhanced edition-aware matching first with expected track count for Smart Edition Matching
            album, confidence = self.check_album_exists_with_editions(title, artist, confidence_threshold, expected_track_count, server_source, candidate_albums=candidate_albums, strict_discography_match=strict_discography_match)

            if not album:
                return None, 0.0, 0, 0, False, []

            # Now check completeness (includes formats)
            owned_tracks, expected_tracks, is_complete, formats = self.check_album_completeness(album.id, expected_track_count)

            return album, confidence, owned_tracks, expected_tracks, is_complete, formats

        except Exception as e:
            logger.error(f"Error checking album existence with completeness for '{title}' by '{artist}': {e}")
            return None, 0.0, 0, 0, False, []
    
    def check_album_exists_with_editions(self, title: str, artist: str, confidence_threshold: float = 0.8, expected_track_count: Optional[int] = None, server_source: Optional[str] = None, candidate_albums: Optional[List[DatabaseAlbum]] = None, strict_discography_match: bool = False) -> Tuple[Optional[DatabaseAlbum], float]:
        """
        Enhanced album existence check that handles edition variants.
        Matches standard albums with deluxe/platinum/special editions and vice versa.

        When `candidate_albums` is provided, the artist-level SQL searches are
        skipped and matching runs in-memory against that list — used by callers
        that already fetched the artist's full library via
        get_candidate_albums_for_artist, so a discography of N items doesn't
        trigger N*K SQL queries. The title-only cross-artist fallback for
        collaborative albums is preserved in both paths.
        """
        try:
            best_match = None
            best_confidence = 0.0

            if candidate_albums is not None:
                # BATCHED PATH — score every pre-fetched candidate in-memory.
                # _calculate_album_confidence handles title normalization and
                # expected-track-count edition matching, so we don't need the
                # per-variation SQL widening that the legacy path does.
                logger.debug(f"Edition matching for '{title}' by '{artist}': batched against {len(candidate_albums)} candidates")
                for album in candidate_albums:
                    confidence = self._calculate_album_confidence(title, artist, album, expected_track_count, strict_discography_match=strict_discography_match)
                    if confidence > best_confidence:
                        best_confidence = confidence
                        best_match = album
            else:
                # LEGACY PATH — generate title variations and fire SQL per variation.
                title_variations = self._generate_album_title_variations(title)

                logger.debug(f"Edition matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
                for i, var in enumerate(title_variations):
                    logger.debug(f"  {i+1}. '{var}'")

                for variation in title_variations:
                    # Search for this variation
                    albums = []
                    artist_variations = self._get_artist_variations(artist)
                    for artist_variation in artist_variations:
                        found = self.search_albums(title=variation, artist=artist_variation, limit=10, server_source=server_source)
                        # Deduplicate by ID
                        existing_ids = {a.id for a in albums}
                        for album in found:
                            if album.id not in existing_ids:
                                albums.append(album)
                                existing_ids.add(album.id)

                    if albums:
                        logger.debug(f"Found {len(albums)} albums for variation '{variation}'")

                    if not albums:
                        continue

                    # Score each potential match with Smart Edition Matching
                    for album in albums:
                        confidence = self._calculate_album_confidence(title, artist, album, expected_track_count, strict_discography_match=strict_discography_match)
                        logger.debug(f"  '{album.title}' confidence: {confidence:.3f}")

                        if confidence > best_confidence:
                            best_confidence = confidence
                            best_match = album

                # Return match only if it meets threshold
                if best_match and best_confidence >= confidence_threshold:
                    logger.debug(f"Edition match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
                    return best_match, best_confidence

                # Fallback: Check ALL albums by this artist (resolves SQL accent sensitivity issues #101)
                # Only runs in the legacy path — batched callers have already
                # fetched this broader list via get_candidate_albums_for_artist.
                if best_confidence < confidence_threshold:
                    logger.debug(f"specific title search failed, trying broad artist search fallback for '{artist}'")
                    try:
                        # Get ALL albums by this artist (limit 100 to be safe)
                        # This bypasses SQL 'LIKE' limitations for diacritics (e.g. 'ă' vs 'a')
                        # And relies on Python-side normalization in _calculate_album_confidence
                        artist_albums = []
                        artist_variations = self._get_artist_variations(artist)
                        for artist_var in artist_variations:
                            found_albums = self.search_albums(title="", artist=artist_var, limit=100, server_source=server_source)
                            # Deduplicate
                            existing_ids = {a.id for a in artist_albums}
                            for album in found_albums:
                                if album.id not in existing_ids:
                                    artist_albums.append(album)
                                    existing_ids.add(album.id)

                        if artist_albums:
                            logger.debug(f"  Found {len(artist_albums)} total albums for artist fallback")

                        for album in artist_albums:
                            confidence = self._calculate_album_confidence(title, artist, album, expected_track_count, strict_discography_match=strict_discography_match)
                            if confidence > best_confidence:
                                best_confidence = confidence
                                best_match = album
                                logger.debug(f"  Fallback match: '{album.title}' confidence: {confidence:.3f}")
                    except Exception as fallback_error:
                         logger.warning(f"Fallback artist search failed: {fallback_error}")

            if best_match and best_confidence >= confidence_threshold:
                 logger.debug(f"Match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
                 return best_match, best_confidence

            # Multi-artist fallback: search by title only (any artist)
            # Handles collaborative albums filed under a different artist in the library
            if best_confidence < confidence_threshold:
                logger.debug(f"Artist-specific search failed, trying title-only fallback for '{title}'")
                try:
                    title_only_albums = self.search_albums(title=title, artist="", limit=20, server_source=server_source)
                    for album in title_only_albums:
                        confidence = self._calculate_album_confidence(title, artist, album, expected_track_count, strict_discography_match=strict_discography_match)
                        # Slightly penalize cross-artist matches to prefer same-artist when possible
                        if confidence > best_confidence:
                            best_confidence = confidence
                            best_match = album
                            logger.debug(f"  Title-only match: '{album.title}' (confidence: {confidence:.3f})")
                except Exception as title_error:
                    logger.warning(f"Title-only fallback search failed: {title_error}")

            if best_match and best_confidence >= confidence_threshold:
                logger.debug(f"Title-only match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
                return best_match, best_confidence

            logger.debug(f"No confident edition match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})")
            return None, best_confidence
                
        except Exception as e:
            logger.error(f"Error in edition-aware album matching for '{title}' by '{artist}': {e}")
            return None, 0.0
    
    def _generate_album_title_variations(self, title: str) -> List[str]:
        """Generate variations of album title to handle edition matching"""
        variations = [title]  # Always include original

        # Add diacritic-normalized variation (fixes #101)
        # SQLite LIKE is not Unicode-aware, so "găină" won't match "gaina"
        # Adding the normalized form lets the SQL query catch both
        normalized_title = self._normalize_for_comparison(title)
        if normalized_title != title.lower():
            variations.append(normalized_title)

        # Clean up the title
        title_lower = title.lower().strip()

        # Define edition patterns and their variations
        # Specific patterns first, generic catch-alls last (first match wins due to break)
        edition_patterns = {
            r'\s*\(deluxe\s*edition?\)': ['deluxe', 'deluxe edition'],
            r'\s*\(expanded\s*edition?\)': ['expanded', 'expanded edition'],
            r'\s*\(platinum\s*edition?\)': ['platinum', 'platinum edition'],
            r'\s*\(special\s*edition?\)': ['special', 'special edition'],
            r'\s*\(remastered?\)': ['remastered', 'remaster'],
            r'\s*\(anniversary\s*edition?\)': ['anniversary', 'anniversary edition'],
            r'\s*\(.*version\)': ['version'],
            r'\s+deluxe\s*edition?$': ['deluxe', 'deluxe edition'],
            r'\s+platinum\s*edition?$': ['platinum', 'platinum edition'],
            r'\s+special\s*edition?$': ['special', 'special edition'],
            r'\s*-\s*deluxe': ['deluxe'],
            r'\s*-\s*platinum\s*edition?': ['platinum', 'platinum edition'],
            r'\s+collector\'?s?\s*edition?$': ['collectors', 'collectors edition'],
            r'\s*\(collector\'?s?\s*edition?\)': ['collectors', 'collectors edition'],
            # Generic catch-alls for any edition in parens/brackets (e.g. Silver Edition, MMXI Special Edition)
            r'\s*\([^)]*\bedition\b[^)]*\)': ['edition'],
            r'\s*\[[^\]]*\bedition\b[^\]]*\]': ['edition'],
        }
        
        # Check if title contains any edition indicators
        base_title = title
        found_editions = []
        
        for pattern, edition_types in edition_patterns.items():
            if re.search(pattern, title_lower):
                # Remove the edition part to get base title
                base_title = re.sub(pattern, '', title, flags=re.IGNORECASE).strip()
                found_editions.extend(edition_types)
                break
        
        # Add base title (without edition markers)
        if base_title != title:
            variations.append(base_title)
        
        # If we found a base title, add common edition variants
        if base_title != title:
            # Add common deluxe/platinum/special variants
            common_editions = [
                'deluxe edition',
                'deluxe',
                'platinum edition',
                'platinum',
                'special edition',
                'expanded edition',
                'remastered',
                'anniversary edition',
                "collector's edition",
                'collectors edition',
            ]
            
            for edition in common_editions:
                variations.extend([
                    f"{base_title} ({edition.title()})",
                    f"{base_title} ({edition})",
                    f"{base_title} - {edition.title()}",
                    f"{base_title} {edition.title()}",
                ])
        
        # If original title is base form, add edition variants  
        elif not any(re.search(pattern, title_lower) for pattern in edition_patterns.keys()):
            # This appears to be a base album, add deluxe variants
            common_editions = ['Deluxe Edition', 'Deluxe', 'Platinum Edition', 'Special Edition', "Collector's Edition", 'Collectors Edition']
            for edition in common_editions:
                variations.extend([
                    f"{title} ({edition})",
                    f"{title} - {edition}",
                    f"{title} {edition}",
                ])
        
        # Remove duplicates while preserving order
        seen = set()
        unique_variations = []
        for var in variations:
            var_clean = var.strip()
            if var_clean and var_clean.lower() not in seen:
                seen.add(var_clean.lower())
                unique_variations.append(var_clean)
        
        return unique_variations
    
    def _calculate_album_confidence(self, search_title: str, search_artist: str, db_album: DatabaseAlbum, expected_track_count: Optional[int] = None, strict_discography_match: bool = False) -> float:
        """Calculate confidence score for album match with Smart Edition Matching"""
        try:
            # Simple confidence based on string similarity
            title_similarity = self._string_similarity(search_title.lower(), db_album.title.lower())
            artist_similarity = self._string_similarity(search_artist.lower(), db_album.artist_name.lower())

            # Also try with cleaned versions (removing edition markers)
            clean_search_title = self._clean_album_title_for_comparison(search_title)
            clean_db_title = self._clean_album_title_for_comparison(db_album.title)
            clean_title_similarity = self._string_similarity(clean_search_title, clean_db_title)

            # Also try with normalized versions (handling diacritics) - fixes #101
            normalized_search_title = self._normalize_for_comparison(search_title)
            normalized_db_title = self._normalize_for_comparison(db_album.title)
            normalized_title_similarity = self._string_similarity(normalized_search_title, normalized_db_title)

            # Use the best title similarity
            best_title_similarity = max(title_similarity, clean_title_similarity, normalized_title_similarity)

            if strict_discography_match and not self._passes_strict_discography_album_match(
                search_title,
                db_album.title,
                title_similarity,
                clean_title_similarity,
                normalized_title_similarity,
                expected_track_count,
                db_album.track_count,
            ):
                logger.debug("  Strict discography match rejected: '%s' -> '%s'", search_title, db_album.title)
                return 0.0

            # 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):
                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
            # carrying a bad title match over the threshold (e.g. "divisions" vs "silos")
            if best_title_similarity < 0.6:
                return best_title_similarity * 0.5  # Can never exceed 0.3, well below any threshold

            # Weight: 50% title, 50% artist (equal weight to prevent false positives)
            # Also require minimum artist similarity to prevent matching wrong artists
            confidence = (best_title_similarity * 0.5) + (artist_similarity * 0.5)

            # Apply artist similarity penalty: if artist match is too low, drastically reduce confidence
            if artist_similarity < 0.6:  # Less than 60% artist match
                confidence *= 0.3  # Reduce confidence by 70%

            # Smart Edition Matching: Boost confidence if we found a "better" edition
            if expected_track_count and db_album.track_count and clean_title_similarity >= 0.8:
                # If the cleaned titles match well, check if this is an edition upgrade
                if db_album.track_count >= expected_track_count:
                    # 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)
                    confidence += edition_bonus
                    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:
                    # Found significantly smaller edition, apply penalty
                    edition_penalty = 0.1
                    confidence -= edition_penalty
                    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
            
        except Exception as e:
            logger.error(f"Error calculating album confidence: {e}")
            return 0.0

    def _passes_strict_discography_album_match(
        self,
        search_title: str,
        db_title: str,
        title_similarity: float,
        clean_title_similarity: float,
        normalized_title_similarity: float,
        expected_track_count: Optional[int],
        db_track_count: Optional[int],
    ) -> bool:
        """Guard artist-page owned status against generic soundtrack false positives."""
        if not self._is_soundtrack_like_album_title(search_title) and not self._is_soundtrack_like_album_title(db_title):
            return True

        normalized_search_title = self._normalize_for_comparison(search_title)
        normalized_db_title = self._normalize_for_comparison(db_title)
        if normalized_search_title == normalized_db_title:
            return True

        clean_search_title = self._normalize_for_comparison(self._clean_album_title_for_comparison(search_title))
        clean_db_title = self._normalize_for_comparison(self._clean_album_title_for_comparison(db_title))
        if clean_search_title and clean_search_title == clean_db_title:
            return True

        best_title_similarity = max(title_similarity, clean_title_similarity, normalized_title_similarity)
        search_tokens = self._distinctive_soundtrack_title_tokens(search_title)
        db_tokens = self._distinctive_soundtrack_title_tokens(db_title)
        if not search_tokens or not db_tokens:
            return False

        shared_tokens = search_tokens & db_tokens
        smaller_overlap = len(shared_tokens) / min(len(search_tokens), len(db_tokens))
        jaccard_overlap = len(shared_tokens) / len(search_tokens | db_tokens)
        if smaller_overlap < 0.75 or jaccard_overlap < 0.55:
            return False

        if expected_track_count and db_track_count and best_title_similarity < 0.9:
            track_ratio = min(expected_track_count, db_track_count) / max(expected_track_count, db_track_count)
            if track_ratio < 0.5:
                return False

        return True

    def _is_soundtrack_like_album_title(self, title: str) -> bool:
        title = (title or "").lower()
        patterns = [
            r"\bsoundtrack\b",
            r"\bscore\b",
            r"\bost\b",
            r"original\s+motion\s+picture",
            r"music\s+from\s+(?:the\s+)?(?:motion\s+picture|film|movie|series|anime|tv|television)",
            r"complete\s+recordings?",
        ]
        return any(re.search(pattern, title) for pattern in patterns)

    def _distinctive_soundtrack_title_tokens(self, title: str) -> set[str]:
        normalized = self._normalize_for_comparison(title)
        tokens = set(re.findall(r"[a-z0-9]+", normalized))
        noise = {
            "album",
            "anime",
            "complete",
            "deluxe",
            "edition",
            "film",
            "from",
            "motion",
            "movie",
            "music",
            "official",
            "original",
            "ost",
            "picture",
            "recording",
            "recordings",
            "score",
            "series",
            "soundtrack",
            "special",
            "television",
            "the",
            "tv",
            "version",
        }
        return {token for token in tokens if token not in noise and len(token) > 1}
    
    def _generate_track_title_variations(self, title: str) -> List[str]:
        """Generate variations of track title for better matching"""
        variations = [title]  # Always include original

        # Add diacritic-normalized variation (fixes #101)
        normalized_title = self._normalize_for_comparison(title)
        if normalized_title != title.lower():
            variations.append(normalized_title)

        # IMPORTANT: Generate bracket/dash style variations for better matching
        # Convert "Track - Instrumental" to "Track (Instrumental)" and vice versa
        if ' - ' in title:
            # Convert dash style to parentheses style
            dash_parts = title.split(' - ', 1)
            if len(dash_parts) == 2:
                paren_version = f"{dash_parts[0]} ({dash_parts[1]})"
                variations.append(paren_version)
        
        if '(' in title and ')' in title:
            # Convert parentheses style to dash style
            dash_version = re.sub(r'\s*\(([^)]+)\)\s*', r' - \1', title)
            if dash_version != title:
                variations.append(dash_version)
        
        # Clean up the title
        title_lower = title.lower().strip()
        
        # Conservative track title variations - only remove clear noise, preserve meaningful differences
        track_patterns = [
            # Remove explicit/clean markers only
            r'\s*\(explicit\)',
            r'\s*\(clean\)',
            r'\s*\[explicit\]',
            r'\s*\[clean\]',
            # Remove featuring artists in parentheses
            r'\s*\(.*feat\..*\)',
            r'\s*\(.*featuring.*\)',
            r'\s*\(.*ft\..*\)',
            # Remove radio/TV edit markers
            r'\s*\(radio\s*edit\)',
            r'\s*\(tv\s*edit\)',
            r'\s*\[radio\s*edit\]',
            r'\s*\[tv\s*edit\]',
        ]
        
        # DO NOT remove remixes, versions, or content after dashes
        # These are meaningful distinctions that should not be collapsed
        
        for pattern in track_patterns:
            # Apply pattern to original title
            cleaned = re.sub(pattern, '', title, flags=re.IGNORECASE).strip()
            if cleaned and cleaned.lower() != title_lower and cleaned not in variations:
                variations.append(cleaned)
            
            # Apply pattern to lowercase version
            cleaned_lower = re.sub(pattern, '', title_lower, flags=re.IGNORECASE).strip()
            if cleaned_lower and cleaned_lower != title_lower:
                # Convert back to proper case
                cleaned_proper = cleaned_lower.title()
                if cleaned_proper not in variations:
                    variations.append(cleaned_proper)
        
        # Remove duplicates while preserving order
        seen = set()
        unique_variations = []
        for var in variations:
            var_key = var.lower().strip()
            if var_key not in seen and var.strip():
                seen.add(var_key)
                unique_variations.append(var.strip())
        
        return unique_variations
    
    def _normalize_for_comparison(self, text: str) -> str:
        """Normalize text for comparison with Unicode accent handling"""
        if not text:
            return ""
        
        # Try to use unidecode for accent normalization, fallback to basic if not available
        try:
            from unidecode import unidecode
            # Convert accents: é→e, ñ→n, ü→u, etc.
            normalized = unidecode(text)
        except ImportError:
            # Fallback: basic normalization without accent handling
            normalized = text
            logger.warning("unidecode not available, accent matching may be limited")
        
        # Convert to lowercase and strip
        return normalized.lower().strip()
    
    def _calculate_track_confidence(self, search_title: str, search_artist: str, db_track: DatabaseTrack) -> float:
        """Calculate confidence score for track match with enhanced cleaning and Unicode normalization"""
        try:
            # Unicode-aware normalization for accent matching (é→e, ñ→n, etc.)
            search_title_norm = self._normalize_for_comparison(search_title)
            search_artist_norm = self._normalize_for_comparison(search_artist)
            db_title_norm = self._normalize_for_comparison(db_track.title)
            db_artist_norm = self._normalize_for_comparison(db_track.artist_name)
            
            # Debug logging for Unicode normalization
            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:
                logger.debug("Unicode normalization:")
                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}'")
            
            # Direct similarity with Unicode normalization
            title_similarity = self._string_similarity(search_title_norm, db_title_norm)
            artist_similarity = self._string_similarity(search_artist_norm, db_artist_norm)

            # Soundtracks/compilations: the album-level artist (artists.name via JOIN)
            # often differs from the per-track artist (e.g. Vaiana OST is filed under
            # Lin-Manuel Miranda but "Where You Are" is performed by Christopher
            # Jackson). Score against tracks.track_artist too and take the better
            # match so playlist sync can find these.
            #
            # Featured artists: tracks with multiple credits ("Artist1, Artist2",
            # "Artist1 feat. Artist2", "Artist1 & Artist2") split on common
            # delimiters and score each piece independently. Without this, a
            # discography completion check for Artist2 would miss a track stored
            # in the library under Artist1's album with a "feat. Artist2" credit.
            db_track_artist = getattr(db_track, 'track_artist', None)
            if db_track_artist:
                db_track_artist_norm = self._normalize_for_comparison(db_track_artist)
                # Whole-string similarity first as the floor.
                track_artist_sim = self._string_similarity(search_artist_norm, db_track_artist_norm)
                # Then split on multi-artist delimiters and score each piece —
                # Spotify's "feat.", "ft.", commas, semicolons, ampersands, and
                # "x" between names all show up here in real-world tags.
                pieces = re.split(
                    r'\s*(?:[;,&]|\bfeat\.?\b|\bft\.?\b|\bfeaturing\b|\bvs\.?\b|\bx\b)\s*',
                    db_track_artist_norm,
                    flags=re.IGNORECASE,
                )
                for piece in pieces:
                    piece = piece.strip()
                    if not piece:
                        continue
                    piece_sim = self._string_similarity(search_artist_norm, piece)
                    if piece_sim > track_artist_sim:
                        track_artist_sim = piece_sim
                artist_similarity = max(artist_similarity, track_artist_sim)
            
            # Also try with cleaned versions (removing parentheses, brackets, etc.)
            clean_search_title = self._clean_track_title_for_comparison(search_title)
            clean_db_title = self._clean_track_title_for_comparison(db_track.title)
            clean_title_similarity = self._string_similarity(clean_search_title, clean_db_title)
            
            # Use the best title similarity (direct or cleaned)
            best_title_similarity = max(title_similarity, clean_title_similarity)

            # Length ratio penalty: if the DB title is significantly longer/shorter than the
            # search title, it's likely a different track (e.g. "Believe" vs "Believe In Me").
            # SequenceMatcher gives high scores when the shorter string is fully contained
            # in the longer one, which causes false positives for prefix/suffix matches.
            len_search = len(clean_search_title) if clean_search_title else len(search_title_norm)
            len_db = len(clean_db_title) if clean_db_title else len(db_title_norm)
            if len_search > 0 and len_db > 0:
                len_ratio = min(len_search, len_db) / max(len_search, len_db)
                if len_ratio < 0.7:
                    # Titles differ in length by more than 30% — penalize heavily
                    best_title_similarity *= len_ratio

            # Require minimum title similarity to prevent a perfect artist match from
            # carrying a bad title match over the threshold (e.g. "Time" vs "Time Flies")
            if best_title_similarity < 0.6:
                return best_title_similarity * 0.5  # Can never exceed 0.3, well below any threshold

            # Weight: 50% title, 50% artist (equal weight to prevent false positives)
            # Also require minimum artist similarity to prevent matching wrong artists
            confidence = (best_title_similarity * 0.5) + (artist_similarity * 0.5)

            # Apply artist similarity penalty: if artist match is too low, drastically reduce confidence
            if artist_similarity < 0.6:  # Less than 60% artist match
                confidence *= 0.3  # Reduce confidence by 70%

            return confidence
            
        except Exception as e:
            logger.error(f"Error calculating track confidence: {e}")
            return 0.0
    
    def _clean_track_title_for_comparison(self, title: str) -> str:
        """Clean track title for comparison by normalizing brackets/dashes and removing noise"""
        cleaned = title.lower().strip()

        # PRE-STEP: Handle "(with Artist)" featuring BEFORE bracket removal.
        # This catches "with" only when used as featuring syntax inside brackets,
        # NOT when "with" is part of the song title like "Stay With Me".
        # e.g. "Levitating (with DaBaby)" → "Levitating"
        #      "Stay (with Justin Bieber)" → "Stay"
        #      "Stay With Me" → unchanged (no brackets around "with")
        cleaned = re.sub(r'\s*\(with\s+[^)]*\)', '', cleaned, flags=re.IGNORECASE)
        cleaned = re.sub(r'\s*\[with\s+[^\]]*\]', '', cleaned, flags=re.IGNORECASE)

        # STEP 1: Normalize bracket/dash styles for consistent matching
        # Convert all bracket styles to spaces for better matching
        cleaned = re.sub(r'\s*[\[\(]\s*', ' ', cleaned)  # Convert opening brackets/parens to space
        cleaned = re.sub(r'\s*[\]\)]\s*', ' ', cleaned)  # Convert closing brackets/parens to space
        cleaned = re.sub(r'\s*-\s*', ' ', cleaned)       # Convert dashes to spaces too

        # STEP 2: Remove metadata noise for better matching
        # IMPORTANT: Only remove markers that describe the SAME recording with different metadata
        # DO NOT remove markers that indicate DIFFERENT versions (live, remix, acoustic, etc.)
        # Those are handled by the matching engine's version detection system
        patterns_to_remove = [
            # Basic markers (content/parental ratings)
            r'\s*explicit\s*',      # Remove explicit markers
            r'\s*clean\s*',         # Remove clean markers

            # Featuring/collaboration (metadata, not different version)
            r'\s*feat\..*',         # Remove featuring
            r'\s*featuring.*',      # Remove featuring
            r'\s*ft\..*',           # Remove ft.

            # Remasters (same recording, different mastering)
            r'\s*\d{4}\s*remaster.*',  # Remove "2015 remaster"
            r'\s*remaster.*',       # Remove "remaster/remastered"
            r'\s*remastered.*',     # Remove "remastered"

            # NOTE: Edit versions (radio edit, single edit, album edit) are NOT
            # removed here — they are treated as different versions by
            # matching_engine.similarity_score() which applies a 0.30 penalty.
            # Removing them here would override that penalty via max() and
            # cause incorrect matches (e.g. radio edit matched to full version).

            # Version clarifications (metadata, not different recordings)
            r'\s*original\s+version.*',  # Remove "original version" - clarification
            r'\s*album\s+version.*',     # Remove "album version" - clarification
            r'\s*single\s+version.*',    # Remove "single version" - clarification
            r'\s*version\s*$',           # Remove trailing "version"

            # Soundtrack/source info (metadata about source)
            r'\s*from\s+.*soundtrack.*', # Remove "from ... soundtrack"
            r'\s*from\s+".*".*',         # Remove "from 'Movie Title'"
            r'\s*soundtrack.*',          # Remove "soundtrack"
        ]

        # NOTE: We do NOT remove these - they indicate DIFFERENT recordings:
        # - live, live at, live from, unplugged (different performance)
        # - remix, mix (different mix)
        # - acoustic (different arrangement)
        # - instrumental (different version)
        # - demo (different recording)
        # - extended (different length/content)
        # - radio edit, single edit, album edit (different cuts)
        # These are handled by matching_engine.similarity_score() which applies penalties

        for pattern in patterns_to_remove:
            cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE).strip()

        # STEP 3: Clean up extra spaces
        cleaned = re.sub(r'\s+', ' ', cleaned).strip()

        return cleaned
    
    def _clean_album_title_for_comparison(self, title: str) -> str:
        """Clean album title by removing edition markers for comparison"""
        cleaned = title.lower()

        # Remove common edition patterns (specific first, then generic catch-alls)
        patterns = [
            r'\s*\(deluxe\s*edition?\)',
            r'\s*\(expanded\s*edition?\)',
            r'\s*\(platinum\s*edition?\)',
            r'\s*\(special\s*edition?\)',
            r'\s*\(remastered?\)',
            r'\s*\(anniversary\s*edition?\)',
            r'\s*\(.*version\)',
            r'\s*-\s*deluxe\s*edition?',
            r'\s*-\s*platinum\s*edition?',
            r'\s+deluxe\s*edition?$',
            r'\s+platinum\s*edition?$',
            # Generic catch-alls: any parenthesized/bracketed text containing "edition"
            # Handles "Silver Edition", "MMXI Special Edition", "Limited Edition", etc.
            r'\s*\([^)]*\bedition\b[^)]*\)',
            r'\s*\[[^\]]*\bedition\b[^\]]*\]',
            r'\s*-\s+\w+\s+edition\s*$',
        ]

        for pattern in patterns:
            cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE)

        return cleaned.strip()
    
    def get_album_completion_stats(self, artist_name: str) -> Dict[str, int]:
        """
        Get completion statistics for all albums by an artist.
        Returns dict with counts of complete, partial, and missing albums.
        """
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            
            # Get all albums by this artist with track counts
            cursor.execute("""
                SELECT albums.id, albums.track_count, COUNT(tracks.id) as actual_tracks
                FROM albums
                JOIN artists ON albums.artist_id = artists.id
                LEFT JOIN tracks ON albums.id = tracks.album_id
                WHERE artists.name LIKE ?
                GROUP BY albums.id, albums.track_count
            """, (f"%{artist_name}%",))
            
            results = cursor.fetchall()
            stats = {
                'complete': 0,          # >=90% of tracks
                'nearly_complete': 0,   # 80-89% of tracks
                'partial': 0,           # 1-79% of tracks  
                'missing': 0,           # 0% of tracks
                'total': len(results)
            }
            
            for row in results:
                expected_tracks = row['track_count'] or 1  # Avoid division by zero
                actual_tracks = row['actual_tracks']
                completion_ratio = actual_tracks / expected_tracks
                
                if actual_tracks == 0:
                    stats['missing'] += 1
                elif completion_ratio >= 0.9:
                    stats['complete'] += 1
                elif completion_ratio >= 0.8:
                    stats['nearly_complete'] += 1
                else:
                    stats['partial'] += 1
            
            return stats
            
        except Exception as e:
            logger.error(f"Error getting album completion stats for artist '{artist_name}': {e}")
            return {'complete': 0, 'nearly_complete': 0, 'partial': 0, 'missing': 0, 'total': 0}
    
    def set_metadata(self, key: str, value: str):
        """Set a metadata value"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    INSERT OR REPLACE INTO metadata (key, value, updated_at) 
                    VALUES (?, ?, CURRENT_TIMESTAMP)
                """, (key, value))
                conn.commit()
        except Exception as e:
            logger.error(f"Error setting metadata {key}: {e}")
    
    def get_metadata(self, key: str) -> Optional[str]:
        """Get a metadata value"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("SELECT value FROM metadata WHERE key = ?", (key,))
                result = cursor.fetchone()
                return result['value'] if result else None
        except Exception as e:
            logger.error(f"Error getting metadata {key}: {e}")
            return None
    
    def record_full_refresh_completion(self):
        """Record when a full refresh was completed"""
        from datetime import datetime
        self.set_metadata('last_full_refresh', datetime.now().isoformat())
    
    def get_last_full_refresh(self) -> Optional[str]:
        """Get the date of the last full refresh"""
        return self.get_metadata('last_full_refresh')

    def set_preference(self, key: str, value: str):
        """Set a user preference (alias for set_metadata for clarity)"""
        self.set_metadata(key, value)

    def get_preference(self, key: str) -> Optional[str]:
        """Get a user preference (alias for get_metadata for clarity)"""
        return self.get_metadata(key)

    # --- Bubble Snapshot Methods ---

    def save_bubble_snapshot(self, snapshot_type: str, data_dict: dict, profile_id: int = 1):
        """Save a bubble snapshot (upserts by type + profile).

        Args:
            snapshot_type: One of 'artist_bubbles', 'search_bubbles', 'discover_downloads'
            data_dict: The bubbles/downloads dict to persist
            profile_id: Profile to save for
        """
        from datetime import datetime
        now = datetime.now()
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                # Check if profile_id column exists
                cursor.execute("PRAGMA table_info(bubble_snapshots)")
                cols = {c[1] for c in cursor.fetchall()}
                if 'profile_id' in cols:
                    # Delete existing entry for this profile+type, then insert
                    cursor.execute("DELETE FROM bubble_snapshots WHERE type = ? AND profile_id = ?",
                                   (snapshot_type, profile_id))
                    cursor.execute(
                        "INSERT INTO bubble_snapshots (type, data, timestamp, snapshot_id, profile_id) VALUES (?, ?, ?, ?, ?)",
                        (snapshot_type, json.dumps(data_dict), now.isoformat(), now.strftime('%Y%m%d_%H%M%S'), profile_id)
                    )
                else:
                    cursor.execute(
                        "INSERT OR REPLACE INTO bubble_snapshots (type, data, timestamp, snapshot_id) VALUES (?, ?, ?, ?)",
                        (snapshot_type, json.dumps(data_dict), now.isoformat(), now.strftime('%Y%m%d_%H%M%S'))
                    )
                conn.commit()
        except Exception as e:
            logger.error(f"Error saving bubble snapshot '{snapshot_type}': {e}")
            raise

    def get_bubble_snapshot(self, snapshot_type: str, profile_id: int = 1) -> Optional[Dict[str, Any]]:
        """Load a bubble snapshot for the given profile.

        Returns:
            {'data': dict, 'timestamp': str} or None if not found
        """
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("PRAGMA table_info(bubble_snapshots)")
                cols = {c[1] for c in cursor.fetchall()}
                if 'profile_id' in cols:
                    cursor.execute("SELECT data, timestamp FROM bubble_snapshots WHERE type = ? AND profile_id = ?",
                                   (snapshot_type, profile_id))
                else:
                    cursor.execute("SELECT data, timestamp FROM bubble_snapshots WHERE type = ?", (snapshot_type,))
                row = cursor.fetchone()
                if row:
                    return {'data': json.loads(row['data']), 'timestamp': row['timestamp']}
                return None
        except Exception as e:
            logger.error(f"Error getting bubble snapshot '{snapshot_type}': {e}")
            return None

    def delete_bubble_snapshot(self, snapshot_type: str, profile_id: int = 1):
        """Delete a bubble snapshot for the given profile."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("PRAGMA table_info(bubble_snapshots)")
                cols = {c[1] for c in cursor.fetchall()}
                if 'profile_id' in cols:
                    cursor.execute("DELETE FROM bubble_snapshots WHERE type = ? AND profile_id = ?",
                                   (snapshot_type, profile_id))
                else:
                    cursor.execute("DELETE FROM bubble_snapshots WHERE type = ?", (snapshot_type,))
                conn.commit()
        except Exception as e:
            logger.error(f"Error deleting bubble snapshot '{snapshot_type}': {e}")

    # Quality profile management methods

    def get_quality_profile(self) -> dict:
        """Get the quality profile configuration, returns default if not set"""
        import json

        profile_json = self.get_preference('quality_profile')

        if profile_json:
            try:
                profile = json.loads(profile_json)
                # Migrate v1 profiles (min_mb/max_mb) to v2 (min_kbps/max_kbps)
                if profile.get('version', 1) < 2:
                    logger.info("Migrating quality profile from v1 (file size) to v2 (bitrate density)")
                    return self._get_default_quality_profile()
                return profile
            except json.JSONDecodeError:
                logger.error("Failed to parse quality profile JSON, returning default")

        return self._get_default_quality_profile()

    def _get_default_quality_profile(self) -> dict:
        """Return the default v2 quality profile (balanced preset)"""
        return {
            "version": 2,
            "preset": "balanced",
            "qualities": {
                "flac": {
                    "enabled": True,
                    "min_kbps": 500,
                    "max_kbps": 10000,
                    "priority": 1,
                    "bit_depth": "any"
                },
                "mp3_320": {
                    "enabled": True,
                    "min_kbps": 280,
                    "max_kbps": 500,
                    "priority": 2
                },
                "mp3_256": {
                    "enabled": True,
                    "min_kbps": 200,
                    "max_kbps": 400,
                    "priority": 3
                },
                "mp3_192": {
                    "enabled": False,
                    "min_kbps": 150,
                    "max_kbps": 300,
                    "priority": 4
                }
            },
            "fallback_enabled": True
        }

    def set_quality_profile(self, profile: dict) -> bool:
        """Save quality profile configuration"""
        import json

        try:
            profile_json = json.dumps(profile)
            self.set_preference('quality_profile', profile_json)
            logger.info(f"Quality profile saved: preset={profile.get('preset', 'custom')}")
            return True
        except Exception as e:
            logger.error(f"Failed to save quality profile: {e}")
            return False

    def get_quality_preset(self, preset_name: str) -> dict:
        """Get a predefined quality preset"""
        presets = {
            "audiophile": {
                "version": 2,
                "preset": "audiophile",
                "qualities": {
                    "flac": {
                        "enabled": True,
                        "min_kbps": 500,
                        "max_kbps": 10000,
                        "priority": 1,
                        "bit_depth": "any"
                    },
                    "mp3_320": {
                        "enabled": False,
                        "min_kbps": 280,
                        "max_kbps": 500,
                        "priority": 2
                    },
                    "mp3_256": {
                        "enabled": False,
                        "min_kbps": 200,
                        "max_kbps": 400,
                        "priority": 3
                    },
                    "mp3_192": {
                        "enabled": False,
                        "min_kbps": 150,
                        "max_kbps": 300,
                        "priority": 4
                    }
                },
                "fallback_enabled": False
            },
            "balanced": {
                "version": 2,
                "preset": "balanced",
                "qualities": {
                    "flac": {
                        "enabled": True,
                        "min_kbps": 500,
                        "max_kbps": 10000,
                        "priority": 1,
                        "bit_depth": "any"
                    },
                    "mp3_320": {
                        "enabled": True,
                        "min_kbps": 280,
                        "max_kbps": 500,
                        "priority": 2
                    },
                    "mp3_256": {
                        "enabled": True,
                        "min_kbps": 200,
                        "max_kbps": 400,
                        "priority": 3
                    },
                    "mp3_192": {
                        "enabled": False,
                        "min_kbps": 150,
                        "max_kbps": 300,
                        "priority": 4
                    }
                },
                "fallback_enabled": True
            },
            "space_saver": {
                "version": 2,
                "preset": "space_saver",
                "qualities": {
                    "flac": {
                        "enabled": False,
                        "min_kbps": 500,
                        "max_kbps": 10000,
                        "priority": 4,
                        "bit_depth": "any"
                    },
                    "mp3_320": {
                        "enabled": True,
                        "min_kbps": 280,
                        "max_kbps": 500,
                        "priority": 1
                    },
                    "mp3_256": {
                        "enabled": True,
                        "min_kbps": 200,
                        "max_kbps": 400,
                        "priority": 2
                    },
                    "mp3_192": {
                        "enabled": True,
                        "min_kbps": 150,
                        "max_kbps": 300,
                        "priority": 3
                    }
                },
                "fallback_enabled": True
            }
        }

        return presets.get(preset_name, presets["balanced"])

    # Wishlist management methods
    
    def add_to_wishlist(
        self,
        spotify_track_data: Dict[str, Any] = None,
        failure_reason: str = "Download failed",
        source_type: str = "unknown",
        source_info: Dict[str, Any] = None,
        profile_id: int = 1,
        track_data: Dict[str, Any] = None,
    ) -> bool:
        """Add a failed track to the wishlist for retry"""
        try:
            if track_data is not None and spotify_track_data is None:
                spotify_track_data = track_data

            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Use track ID as unique identifier. Field name stays legacy-compatible.
                track_id = spotify_track_data.get('id')
                if not track_id:
                    logger.error("Cannot add track to wishlist: missing track ID")
                    return False

                from core.library import manual_library_match as _mlm
                if _mlm.get_match_for_track(self, profile_id, spotify_track_data):
                    logger.info(
                        "Skipping wishlist add for manually matched track: '%s' (%s:%s)",
                        spotify_track_data.get('name', 'Unknown Track'),
                        spotify_track_data.get('provider') or spotify_track_data.get('source') or 'unknown',
                        track_id,
                    )
                    return True

                track_name = spotify_track_data.get('name', 'Unknown Track')
                artists = spotify_track_data.get('artists', [])
                if artists:
                    first_artist = artists[0]
                    if isinstance(first_artist, str):
                        artist_name = first_artist
                    elif isinstance(first_artist, dict):
                        artist_name = first_artist.get('name', 'Unknown Artist')
                    else:
                        artist_name = 'Unknown Artist'
                else:
                    artist_name = 'Unknown Artist'

                # Ensure album is a proper dict — repair if needed so display doesn't break
                album = spotify_track_data.get('album')
                if not album or not isinstance(album, dict):
                    spotify_track_data['album'] = {'name': track_name, 'images': []}
                    logger.info(f"Wishlist add: no album info for '{track_name}', using track name as fallback")
                elif not album.get('name') or album.get('name') in ('Unknown Album', ''):
                    album['name'] = track_name
                    logger.info(f"Wishlist add: missing album name for '{track_name}', using track name as fallback")

                # Check for duplicates by track name + artist (not just Spotify ID)
                # When allow_duplicates is True (default), same song from different albums can coexist
                from config.settings import config_manager
                allow_duplicates = config_manager.get('wishlist.allow_duplicate_tracks', True)

                if not allow_duplicates:
                    cursor.execute("""
                        SELECT id, spotify_track_id, spotify_data FROM wishlist_tracks
                        WHERE profile_id = ?
                    """, (profile_id,))

                    existing_tracks = cursor.fetchall()

                    # Check if any existing track has matching name AND artist
                    for existing in existing_tracks:
                        try:
                            existing_data = json.loads(existing['spotify_data'])
                            existing_name = existing_data.get('name', '')
                            existing_artists = existing_data.get('artists', [])
                            if existing_artists:
                                existing_first = existing_artists[0]
                                if isinstance(existing_first, str):
                                    existing_artist = existing_first
                                elif isinstance(existing_first, dict):
                                    existing_artist = existing_first.get('name', '')
                                else:
                                    existing_artist = ''
                            else:
                                existing_artist = ''

                            # Case-insensitive comparison of track name and primary artist
                            if (existing_name.lower() == track_name.lower() and
                                existing_artist.lower() == artist_name.lower()):
                                # Enhance mode: upsert existing entry with enhance bypass context
                                if source_type == 'enhance':
                                    source_json = json.dumps(source_info or {})
                                    cursor.execute("""
                                        UPDATE wishlist_tracks
                                        SET source_type = ?, source_info = ?, failure_reason = ?,
                                            spotify_data = ?, spotify_track_id = ?
                                        WHERE id = ?
                                    """, (source_type, source_json, failure_reason,
                                          json.dumps(spotify_track_data), track_id, existing['id']))
                                    conn.commit()
                                    logger.info(f"Upserted wishlist entry to enhance mode: '{track_name}' by {artist_name}")
                                    return True
                                logger.info(f"Skipping duplicate wishlist entry: '{track_name}' by {artist_name} (already exists as ID: {existing['id']})")
                                return False  # Already exists, don't add duplicate
                        except Exception as parse_error:
                            logger.warning(f"Error parsing existing wishlist track data: {parse_error}")
                            continue

                # Convert data to JSON strings
                spotify_json = json.dumps(spotify_track_data)
                source_json = json.dumps(source_info or {})

                # When allow_duplicates is on, make the key unique per album so the same
                # track from different albums can coexist in the wishlist
                insert_track_id = track_id
                if allow_duplicates:
                    album_obj = spotify_track_data.get('album', {})
                    album_id = album_obj.get('id', '') if isinstance(album_obj, dict) else ''
                    if album_id:
                        # Check if this exact track+album combo already exists
                        composite_id = f"{track_id}::{album_id}"
                        cursor.execute("SELECT id FROM wishlist_tracks WHERE spotify_track_id = ? AND profile_id = ?",
                                       (composite_id, profile_id))
                        if cursor.fetchone():
                            logger.debug(f"Skipping wishlist entry — same track+album already in wishlist: '{track_name}' on '{album_obj.get('name', '')}'")
                            return False
                        # Check if base track_id exists (from a different album)
                        cursor.execute("SELECT id FROM wishlist_tracks WHERE spotify_track_id = ? AND profile_id = ?",
                                       (track_id, profile_id))
                        if cursor.fetchone():
                            # Same track exists from different album — use composite ID
                            insert_track_id = composite_id

                # Insert the track
                cursor.execute("""
                    INSERT OR REPLACE INTO wishlist_tracks
                    (spotify_track_id, spotify_data, failure_reason, source_type, source_info, date_added, profile_id)
                    VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
                """, (insert_track_id, spotify_json, failure_reason, source_type, source_json, profile_id))

                conn.commit()

                logger.info(f"Added track to wishlist: '{track_name}' by {artist_name}")
                return True

        except Exception as e:
            logger.error(f"Error adding track to wishlist: {e}")
            return False
    
    def remove_from_wishlist(self, spotify_track_id: str, profile_id: int = 1) -> bool:
        """Remove a track from the wishlist (typically after successful download)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("DELETE FROM wishlist_tracks WHERE spotify_track_id = ? AND profile_id = ?",
                               (spotify_track_id, profile_id))
                conn.commit()

                if cursor.rowcount > 0:
                    logger.info(f"Removed track from wishlist: {spotify_track_id}")
                    return True
                else:
                    logger.debug(f"Track not found in wishlist: {spotify_track_id}")
                    return False

        except Exception as e:
            logger.error(f"Error removing track from wishlist: {e}")
            return False
    
    def get_wishlist_tracks(self, limit: Optional[int] = None, profile_id: int = 1,
                            offset: int = 0, category: Optional[str] = None) -> List[Dict[str, Any]]:
        """Get tracks in the wishlist for the given profile, ordered by date added
        (oldest first for retry priority).

        Supports SQL-level pagination via limit/offset and optional category
        filtering (singles vs albums) pushed down to SQL using json_extract.
        """
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                query = """
                    SELECT id, spotify_track_id, spotify_data, failure_reason, retry_count,
                           last_attempted, date_added, source_type, source_info
                    FROM wishlist_tracks
                    WHERE profile_id = ?
                """

                params: List[Any] = [profile_id]

                if category == "albums":
                    query += " AND json_extract(spotify_data, '$.album.album_type') = 'album'"
                elif category == "singles":
                    query += (
                        " AND (json_extract(spotify_data, '$.album.album_type') IS NULL"
                        " OR json_extract(spotify_data, '$.album.album_type') != 'album')"
                    )

                query += " ORDER BY date_added"

                if limit:
                    query += " LIMIT ?"
                    params.append(int(limit))
                    if offset:
                        query += " OFFSET ?"
                        params.append(int(offset))

                cursor.execute(query, params)
                rows = cursor.fetchall()
                
                wishlist_tracks = []
                for row in rows:
                    try:
                        spotify_data = json.loads(row['spotify_data'])
                        source_info = json.loads(row['source_info']) if row['source_info'] else {}
                        
                        wishlist_tracks.append({
                            'id': row['id'],
                            'spotify_track_id': row['spotify_track_id'],
                            'spotify_data': spotify_data,
                            'failure_reason': row['failure_reason'],
                            'retry_count': row['retry_count'],
                            'last_attempted': row['last_attempted'],
                            'date_added': row['date_added'],
                            'source_type': row['source_type'],
                            'source_info': source_info
                        })
                    except json.JSONDecodeError as e:
                        logger.error(f"Error parsing wishlist track data: {e}")
                        continue
                
                return wishlist_tracks
                
        except Exception as e:
            logger.error(f"Error getting wishlist tracks: {e}")
            return []

    def update_wishlist_retry(self, spotify_track_id: str, success: bool, error_message: str = None, profile_id: int = 1) -> bool:
        """Update retry count and status for a wishlist track"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                if success:
                    # Remove from ALL profiles' wishlists — track is now in shared library
                    cursor.execute("DELETE FROM wishlist_tracks WHERE spotify_track_id = ?", (spotify_track_id,))
                else:
                    # Increment retry count and update failure reason
                    cursor.execute("""
                        UPDATE wishlist_tracks
                        SET retry_count = retry_count + 1,
                            last_attempted = CURRENT_TIMESTAMP,
                            failure_reason = COALESCE(?, failure_reason)
                        WHERE spotify_track_id = ? AND profile_id = ?
                    """, (error_message, spotify_track_id, profile_id))
                
                conn.commit()
                return cursor.rowcount > 0
                
        except Exception as e:
            logger.error(f"Error updating wishlist retry status: {e}")
            return False
    
    def get_wishlist_count(self, profile_id: int = 1, category: Optional[str] = None) -> int:
        """Get the total number of tracks in the wishlist for the given profile,
        optionally filtered by category ('singles' or 'albums')."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                query = "SELECT COUNT(*) FROM wishlist_tracks WHERE profile_id = ?"
                params: List[Any] = [profile_id]
                if category == "albums":
                    query += " AND json_extract(spotify_data, '$.album.album_type') = 'album'"
                elif category == "singles":
                    query += (
                        " AND (json_extract(spotify_data, '$.album.album_type') IS NULL"
                        " OR json_extract(spotify_data, '$.album.album_type') != 'album')"
                    )
                cursor.execute(query, params)
                result = cursor.fetchone()
                return result[0] if result else 0
        except Exception as e:
            logger.error(f"Error getting wishlist count: {e}")
            return 0
    
    def clear_wishlist(self, profile_id: int = 1) -> bool:
        """Clear all tracks from the wishlist for the given profile"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("DELETE FROM wishlist_tracks WHERE profile_id = ?", (profile_id,))
                cleared_count = cursor.rowcount
                conn.commit()
                logger.info(f"Cleared {cleared_count} tracks from wishlist (profile: {profile_id})")
                return True
        except Exception as e:
            logger.error(f"Error clearing wishlist: {e}")
            return False

    def remove_wishlist_duplicates(self, profile_id: int = 1) -> int:
        """Remove duplicate tracks from wishlist.
        When allow_duplicate_tracks is True, only removes exact duplicates
        (same name + artist + album). When False, removes any track with the
        same name + artist regardless of album.
        Keeps the oldest entry (by date_added) for each duplicate set.
        Returns the number of duplicates removed."""
        try:
            from config.settings import config_manager
            allow_duplicates = config_manager.get('wishlist.allow_duplicate_tracks', True)

            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Get all wishlist tracks for this profile
                cursor.execute("""
                    SELECT id, spotify_track_id, spotify_data, date_added
                    FROM wishlist_tracks
                    WHERE profile_id = ?
                    ORDER BY date_added ASC
                """, (profile_id,))
                all_tracks = cursor.fetchall()

                # Track seen tracks and duplicates to remove
                seen_tracks = {}  # Value: track row id to keep
                duplicates_to_remove = []

                for track in all_tracks:
                    try:
                        track_data = json.loads(track['spotify_data'])
                        track_name = track_data.get('name', '').lower()
                        artists = track_data.get('artists', [])
                        if artists and isinstance(artists[0], dict):
                            artist_name = artists[0].get('name', '').lower()
                        elif artists:
                            artist_name = str(artists[0]).lower()
                        else:
                            artist_name = 'unknown'

                        if allow_duplicates:
                            # Include album in the key so same song from different albums survives
                            album = track_data.get('album', {})
                            album_name = (album.get('name', '') if isinstance(album, dict) else str(album)).lower()
                            key = (track_name, artist_name, album_name)
                        else:
                            key = (track_name, artist_name)

                        if key in seen_tracks:
                            # Duplicate found - mark for removal
                            duplicates_to_remove.append(track['id'])
                            logger.info(f"Found duplicate: '{track_name}' by {artist_name} (ID: {track['id']}, keeping ID: {seen_tracks[key]})")
                        else:
                            # First occurrence - keep this one
                            seen_tracks[key] = track['id']

                    except Exception as parse_error:
                        logger.warning(f"Error parsing wishlist track {track['id']}: {parse_error}")
                        continue

                # Remove all duplicates
                removed_count = 0
                for duplicate_id in duplicates_to_remove:
                    cursor.execute("DELETE FROM wishlist_tracks WHERE id = ?", (duplicate_id,))
                    removed_count += 1

                conn.commit()
                if removed_count > 0:
                    logger.info(f"Removed {removed_count} duplicate tracks from wishlist (allow_duplicates={allow_duplicates})")
                return removed_count

        except Exception as e:
            logger.error(f"Error removing wishlist duplicates: {e}")
            return 0

    # Watchlist operations
    def add_artist_to_watchlist(self, artist_id: str, artist_name: str, profile_id: int = 1, source: str = None) -> bool:
        """Add an artist to the watchlist for monitoring new releases.

        Automatically detects if artist_id is a Spotify ID (alphanumeric) or iTunes/Deezer ID (numeric).
        If the artist already exists (by name match), updates the existing row with the new source ID.
        """
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Check if artist already exists by name (case-insensitive) for this profile
                cursor.execute("""
                    SELECT id, spotify_artist_id, itunes_artist_id, deezer_artist_id,
                           discogs_artist_id, musicbrainz_artist_id
                    FROM watchlist_artists
                    WHERE LOWER(artist_name) = LOWER(?) AND profile_id = ?
                    LIMIT 1
                """, (artist_name, profile_id))
                existing = cursor.fetchone()

                # Detect source: explicit source param, or infer from ID format
                if not source:
                    source = 'itunes' if artist_id.isdigit() else 'spotify'

                if existing:
                    # Artist already on watchlist — update with new source ID if missing
                    col_map = {
                        'spotify': 'spotify_artist_id',
                        'itunes': 'itunes_artist_id',
                        'deezer': 'deezer_artist_id',
                        'discogs': 'discogs_artist_id',
                        'musicbrainz': 'musicbrainz_artist_id',
                    }
                    col = col_map.get(source)
                    if col and not existing[col]:
                        cursor.execute(f"""
                            UPDATE watchlist_artists
                            SET {col} = ?, updated_at = CURRENT_TIMESTAMP
                            WHERE id = ?
                        """, (artist_id, existing['id']))
                        conn.commit()
                        logger.info(f"Updated existing watchlist artist '{artist_name}' with {source} ID: {artist_id}")
                    else:
                        logger.info(f"Artist '{artist_name}' already on watchlist (profile: {profile_id})")
                    return True

                # New artist — insert with the appropriate ID column
                if source == 'deezer':
                    cursor.execute("""
                        INSERT INTO watchlist_artists
                        (deezer_artist_id, artist_name, date_added, updated_at, profile_id)
                        VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
                    """, (artist_id, artist_name, profile_id))
                    logger.info(f"Added artist '{artist_name}' to watchlist (Deezer ID: {artist_id}, profile: {profile_id})")
                elif source == 'itunes':
                    cursor.execute("""
                        INSERT INTO watchlist_artists
                        (itunes_artist_id, artist_name, date_added, updated_at, profile_id)
                        VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
                    """, (artist_id, artist_name, profile_id))
                    logger.info(f"Added artist '{artist_name}' to watchlist (iTunes ID: {artist_id}, profile: {profile_id})")
                elif source == 'discogs':
                    cursor.execute("""
                        INSERT INTO watchlist_artists
                        (discogs_artist_id, artist_name, date_added, updated_at, profile_id)
                        VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
                    """, (artist_id, artist_name, profile_id))
                    logger.info(f"Added artist '{artist_name}' to watchlist (Discogs ID: {artist_id}, profile: {profile_id})")
                elif source == 'musicbrainz':
                    cursor.execute("""
                        INSERT INTO watchlist_artists
                        (musicbrainz_artist_id, artist_name, date_added, updated_at, profile_id)
                        VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
                    """, (artist_id, artist_name, profile_id))
                    logger.info(f"Added artist '{artist_name}' to watchlist (MusicBrainz ID: {artist_id}, profile: {profile_id})")
                else:
                    cursor.execute("""
                        INSERT INTO watchlist_artists
                        (spotify_artist_id, artist_name, date_added, updated_at, profile_id)
                        VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
                    """, (artist_id, artist_name, profile_id))
                    logger.info(f"Added artist '{artist_name}' to watchlist (Spotify ID: {artist_id}, profile: {profile_id})")

                conn.commit()
                return True

        except Exception as e:
            logger.error(f"Error adding artist '{artist_name}' to watchlist: {e}")
            return False

    def remove_artist_from_watchlist(self, artist_id: str, profile_id: int = 1) -> bool:
        """Remove an artist from the watchlist (checks cross-provider artist IDs)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Get artist name for logging (check all ID columns)
                cursor.execute("""
                    SELECT artist_name FROM watchlist_artists
                    WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
                           OR discogs_artist_id = ? OR musicbrainz_artist_id = ?) AND profile_id = ?
                """, (artist_id, artist_id, artist_id, artist_id, artist_id, profile_id))
                result = cursor.fetchone()
                artist_name = result['artist_name'] if result else "Unknown"

                cursor.execute("""
                    DELETE FROM watchlist_artists
                    WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
                           OR discogs_artist_id = ? OR musicbrainz_artist_id = ?) AND profile_id = ?
                """, (artist_id, artist_id, artist_id, artist_id, artist_id, profile_id))

                if cursor.rowcount > 0:
                    conn.commit()
                    logger.info(f"Removed artist '{artist_name}' from watchlist (ID: {artist_id}, profile: {profile_id})")
                    return True
                else:
                    logger.warning(f"Artist with ID {artist_id} not found in watchlist for profile {profile_id}")
                    return False

        except Exception as e:
            logger.error(f"Error removing artist from watchlist (ID: {artist_id}): {e}")
            return False

    def is_artist_in_watchlist(self, artist_id: str, profile_id: int = 1, artist_name: str = None) -> bool:
        """Check if an artist is currently in the watchlist (checks cross-provider IDs and name)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Check all ID columns and optionally artist name
                if artist_name:
                    cursor.execute("""
                        SELECT 1 FROM watchlist_artists
                        WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
                               OR discogs_artist_id = ? OR musicbrainz_artist_id = ?
                               OR LOWER(artist_name) = LOWER(?)) AND profile_id = ?
                        LIMIT 1
                    """, (artist_id, artist_id, artist_id, artist_id, artist_id, artist_name, profile_id))
                else:
                    cursor.execute("""
                        SELECT 1 FROM watchlist_artists
                        WHERE (spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
                               OR discogs_artist_id = ? OR musicbrainz_artist_id = ?) AND profile_id = ?
                        LIMIT 1
                    """, (artist_id, artist_id, artist_id, artist_id, artist_id, profile_id))
                result = cursor.fetchone()

                return result is not None

        except Exception as e:
            logger.error(f"Error checking if artist is in watchlist (ID: {artist_id}): {e}")
            return False

    def get_watchlist_artists(self, profile_id: int = 1) -> List[WatchlistArtist]:
        """Get all artists in the watchlist for the given profile"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Check which columns exist (for migration compatibility)
                cursor.execute("PRAGMA table_info(watchlist_artists)")
                existing_columns = {column[1] for column in cursor.fetchall()}

                # Build SELECT query based on existing columns
                base_columns = ['id', 'spotify_artist_id', 'artist_name', 'date_added',
                               'last_scan_timestamp', 'created_at', 'updated_at']
                optional_columns = ['image_url', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'musicbrainz_artist_id', 'include_albums', 'include_eps', 'include_singles',
                                   'include_live', 'include_remixes', 'include_acoustic', 'include_compilations',
                                   'include_instrumentals', 'lookback_days', 'preferred_metadata_source']

                columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns]

                if 'profile_id' in existing_columns:
                    cursor.execute(f"""
                        SELECT {', '.join(columns_to_select)}
                        FROM watchlist_artists
                        WHERE profile_id = ?
                        ORDER BY date_added DESC
                    """, (profile_id,))
                else:
                    cursor.execute(f"""
                        SELECT {', '.join(columns_to_select)}
                        FROM watchlist_artists
                        ORDER BY date_added DESC
                    """)

                rows = cursor.fetchall()

                watchlist_artists = []
                for row in rows:
                    # Safely get optional columns with defaults (sqlite3.Row uses dict-style access)
                    image_url = row['image_url'] if 'image_url' in existing_columns else None
                    itunes_artist_id = row['itunes_artist_id'] if 'itunes_artist_id' in existing_columns else None
                    deezer_artist_id = row['deezer_artist_id'] if 'deezer_artist_id' in existing_columns else None
                    discogs_artist_id = row['discogs_artist_id'] if 'discogs_artist_id' in existing_columns else None
                    musicbrainz_artist_id = row['musicbrainz_artist_id'] if 'musicbrainz_artist_id' in existing_columns else None
                    include_albums = bool(row['include_albums']) if 'include_albums' in existing_columns else True
                    include_eps = bool(row['include_eps']) if 'include_eps' in existing_columns else True
                    include_singles = bool(row['include_singles']) if 'include_singles' in existing_columns else True
                    include_live = bool(row['include_live']) if 'include_live' in existing_columns else False
                    include_remixes = bool(row['include_remixes']) if 'include_remixes' in existing_columns else False
                    include_acoustic = bool(row['include_acoustic']) if 'include_acoustic' in existing_columns else False
                    include_compilations = bool(row['include_compilations']) if 'include_compilations' in existing_columns else False
                    include_instrumentals = bool(row['include_instrumentals']) if 'include_instrumentals' in existing_columns else False
                    lookback_days = row['lookback_days'] if 'lookback_days' in existing_columns else None
                    preferred_metadata_source = row['preferred_metadata_source'] if 'preferred_metadata_source' in existing_columns else None

                    watchlist_artists.append(WatchlistArtist(
                        id=row['id'],
                        spotify_artist_id=row['spotify_artist_id'],
                        artist_name=row['artist_name'],
                        date_added=datetime.fromisoformat(row['date_added']),
                        last_scan_timestamp=datetime.fromisoformat(row['last_scan_timestamp']) if row['last_scan_timestamp'] else None,
                        created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
                        updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None,
                        image_url=image_url,
                        itunes_artist_id=itunes_artist_id,
                        deezer_artist_id=deezer_artist_id,
                        discogs_artist_id=discogs_artist_id,
                        musicbrainz_artist_id=musicbrainz_artist_id,
                        include_albums=include_albums,
                        include_eps=include_eps,
                        include_singles=include_singles,
                        include_live=include_live,
                        include_remixes=include_remixes,
                        include_acoustic=include_acoustic,
                        include_compilations=include_compilations,
                        include_instrumentals=include_instrumentals,
                        lookback_days=lookback_days,
                        preferred_metadata_source=preferred_metadata_source,
                        profile_id=profile_id
                    ))

                return watchlist_artists

        except Exception as e:
            logger.error(f"Error getting watchlist artists: {e}")
            return []

    # ── Spotify Library Cache ──────────────────────────────────────────

    def upsert_spotify_library_albums(self, albums: list, profile_id: int = 1):
        """Bulk upsert saved Spotify albums into cache table"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                for album in albums:
                    cursor.execute("""
                        INSERT OR REPLACE INTO spotify_library_cache
                        (spotify_album_id, album_name, artist_name, artist_id,
                         release_date, total_tracks, album_type, image_url,
                         date_saved, cached_at, profile_id)
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
                    """, (
                        album['spotify_album_id'],
                        album['album_name'],
                        album['artist_name'],
                        album.get('artist_id'),
                        album.get('release_date'),
                        album.get('total_tracks', 0),
                        album.get('album_type', 'album'),
                        album.get('image_url'),
                        album.get('date_saved'),
                        profile_id,
                    ))
                conn.commit()
                logger.info(f"Upserted {len(albums)} albums into spotify_library_cache")
        except Exception as e:
            logger.error(f"Error upserting spotify library albums: {e}")

    def get_spotify_library_albums(self, offset=0, limit=50, search='', sort='date_saved',
                                    sort_dir='desc', profile_id=1):
        """Get cached Spotify library albums with pagination, search, and sorting.
        Returns (albums_list, total_count)."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                where_clauses = ['profile_id = ?']
                params = [profile_id]

                if search:
                    where_clauses.append('(album_name LIKE ? OR artist_name LIKE ?)')
                    params.extend([f'%{search}%', f'%{search}%'])

                where_sql = ' AND '.join(where_clauses)

                # Count total
                cursor.execute(f"SELECT COUNT(*) as count FROM spotify_library_cache WHERE {where_sql}", params)
                total = cursor.fetchone()['count']

                # Validate sort column
                valid_sorts = {'date_saved', 'artist_name', 'album_name', 'release_date'}
                if sort not in valid_sorts:
                    sort = 'date_saved'
                sort_direction = 'ASC' if sort_dir == 'asc' else 'DESC'

                cursor.execute(f"""
                    SELECT * FROM spotify_library_cache
                    WHERE {where_sql}
                    ORDER BY {sort} {sort_direction}
                    LIMIT ? OFFSET ?
                """, params + [limit, offset])

                albums = []
                for row in cursor.fetchall():
                    albums.append({
                        'id': row['id'],
                        'spotify_album_id': row['spotify_album_id'],
                        'album_name': row['album_name'],
                        'artist_name': row['artist_name'],
                        'artist_id': row['artist_id'],
                        'release_date': row['release_date'],
                        'total_tracks': row['total_tracks'],
                        'album_type': row['album_type'],
                        'image_url': row['image_url'],
                        'date_saved': row['date_saved'],
                    })

                return albums, total

        except Exception as e:
            logger.error(f"Error getting spotify library albums: {e}")
            return [], 0

    def get_spotify_library_album_ids(self, profile_id=1):
        """Get all cached spotify album IDs as a set"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("SELECT spotify_album_id FROM spotify_library_cache WHERE profile_id = ?", (profile_id,))
                return {row['spotify_album_id'] for row in cursor.fetchall()}
        except Exception as e:
            logger.error(f"Error getting spotify library album IDs: {e}")
            return set()

    def remove_spotify_library_albums_not_in(self, keep_ids: set, profile_id=1):
        """Remove cached albums that are no longer in the user's Spotify library"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                if not keep_ids:
                    cursor.execute("DELETE FROM spotify_library_cache WHERE profile_id = ?", (profile_id,))
                else:
                    placeholders = ','.join('?' * len(keep_ids))
                    cursor.execute(f"""
                        DELETE FROM spotify_library_cache
                        WHERE profile_id = ? AND spotify_album_id NOT IN ({placeholders})
                    """, [profile_id] + list(keep_ids))
                removed = cursor.rowcount
                conn.commit()
                if removed > 0:
                    logger.info(f"Removed {removed} un-saved albums from spotify_library_cache")
                return removed
        except Exception as e:
            logger.error(f"Error removing spotify library albums: {e}")
            return 0

    def get_library_spotify_album_ids(self, profile_id=1):
        """Get all spotify_album_id values from the local music library albums table"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT DISTINCT spotify_album_id FROM albums
                    WHERE spotify_album_id IS NOT NULL AND spotify_album_id != ''
                """)
                return {row['spotify_album_id'] for row in cursor.fetchall()}
        except Exception as e:
            logger.error(f"Error getting library spotify album IDs: {e}")
            return set()

    def get_library_album_names(self):
        """Get normalized (artist, album) pairs from library for fuzzy ownership matching"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT LOWER(a.title) as album, LOWER(ar.name) as artist
                    FROM albums a
                    JOIN artists ar ON a.artist_id = ar.id
                """)
                return {(row['artist'], row['album']) for row in cursor.fetchall()}
        except Exception as e:
            logger.error(f"Error getting library album names: {e}")
            return set()

    def get_watchlist_count(self, profile_id: int = 1) -> int:
        """Get the number of artists in the watchlist for the given profile"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("SELECT COUNT(*) as count FROM watchlist_artists WHERE profile_id = ?", (profile_id,))
                result = cursor.fetchone()

                return result['count'] if result else 0

        except Exception as e:
            logger.error(f"Error getting watchlist count: {e}")
            return 0

    def update_watchlist_artist_image(self, artist_id: str, image_url: str) -> bool:
        """Update the image URL for a watchlist artist (checks linked provider IDs)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Check if image_url column exists (for migration compatibility)
                cursor.execute("PRAGMA table_info(watchlist_artists)")
                existing_columns = {column[1] for column in cursor.fetchall()}

                if 'image_url' not in existing_columns:
                    logger.warning("image_url column does not exist in watchlist_artists table. Skipping update. Please restart the app to apply migrations.")
                    return False

                cursor.execute("""
                    UPDATE watchlist_artists
                    SET image_url = ?, updated_at = CURRENT_TIMESTAMP
                    WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
                          OR discogs_artist_id = ? OR musicbrainz_artist_id = ?
                """, (image_url, artist_id, artist_id, artist_id, artist_id, artist_id))

                conn.commit()
                return cursor.rowcount > 0

        except Exception as e:
            logger.error(f"Error updating watchlist artist image: {e}")
            return False

    def update_watchlist_spotify_id(self, watchlist_id: int, spotify_id: str) -> bool:
        """Update the Spotify artist ID for a watchlist artist (cross-provider support)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    UPDATE watchlist_artists
                    SET spotify_artist_id = ?, updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                """, (spotify_id, watchlist_id))

                conn.commit()
                logger.info(f"Updated Spotify ID for watchlist artist {watchlist_id}: {spotify_id}")
                return cursor.rowcount > 0

        except Exception as e:
            logger.error(f"Error updating watchlist Spotify ID: {e}")
            return False

    def update_watchlist_itunes_id(self, watchlist_id: int, itunes_id: str) -> bool:
        """Update the iTunes artist ID for a watchlist artist (cross-provider support)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    UPDATE watchlist_artists
                    SET itunes_artist_id = ?, updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                """, (itunes_id, watchlist_id))

                conn.commit()
                logger.info(f"Updated iTunes ID for watchlist artist {watchlist_id}: {itunes_id}")
                return cursor.rowcount > 0

        except Exception as e:
            logger.error(f"Error updating watchlist iTunes ID: {e}")
            return False

    def update_watchlist_deezer_id(self, watchlist_id: int, deezer_id: str) -> bool:
        """Update the Deezer artist ID for a watchlist artist (cross-provider support)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    UPDATE watchlist_artists
                    SET deezer_artist_id = ?, updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                """, (deezer_id, watchlist_id))

                conn.commit()
                logger.info(f"Updated Deezer ID for watchlist artist {watchlist_id}: {deezer_id}")
                return cursor.rowcount > 0

        except Exception as e:
            logger.error(f"Error updating watchlist Deezer ID: {e}")
            return False

    def update_watchlist_discogs_id(self, watchlist_id: int, discogs_id: str) -> bool:
        """Update the Discogs artist ID for a watchlist artist (cross-provider support)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    UPDATE watchlist_artists
                    SET discogs_artist_id = ?, updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                """, (discogs_id, watchlist_id))
                conn.commit()
                logger.info(f"Updated Discogs ID for watchlist artist {watchlist_id}: {discogs_id}")
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error updating watchlist Discogs ID: {e}")
            return False

    def update_watchlist_musicbrainz_id(self, watchlist_id: int, musicbrainz_id: str) -> bool:
        """Update the MusicBrainz artist ID for a watchlist artist (cross-provider support)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    UPDATE watchlist_artists
                    SET musicbrainz_artist_id = ?, updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                """, (musicbrainz_id, watchlist_id))
                conn.commit()
                logger.info(f"Updated MusicBrainz ID for watchlist artist {watchlist_id}: {musicbrainz_id}")
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error updating watchlist MusicBrainz ID: {e}")
            return False

    def backfill_watchlist_musicbrainz_ids_from_library(self, profile_id: int = 1) -> int:
        """Copy existing library MusicBrainz artist IDs onto matching watchlist rows.

        The MusicBrainz enrichment worker writes IDs to ``artists.musicbrainz_id``.
        Watchlist UI reads ``watchlist_artists.musicbrainz_artist_id``, so this
        bridge lets existing enriched library matches show up as watchlist
        MusicBrainz matches without waiting for a separate watchlist scan.
        """
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    UPDATE watchlist_artists
                    SET musicbrainz_artist_id = (
                            SELECT a.musicbrainz_id
                            FROM artists a
                            WHERE a.musicbrainz_id IS NOT NULL
                              AND a.musicbrainz_id != ''
                              AND (
                                  LOWER(a.name) = LOWER(watchlist_artists.artist_name)
                                  OR (
                                      watchlist_artists.spotify_artist_id IS NOT NULL
                                      AND watchlist_artists.spotify_artist_id != ''
                                      AND a.spotify_artist_id = watchlist_artists.spotify_artist_id
                                  )
                                  OR (
                                      watchlist_artists.itunes_artist_id IS NOT NULL
                                      AND watchlist_artists.itunes_artist_id != ''
                                      AND a.itunes_artist_id = watchlist_artists.itunes_artist_id
                                  )
                                  OR (
                                      watchlist_artists.deezer_artist_id IS NOT NULL
                                      AND watchlist_artists.deezer_artist_id != ''
                                      AND a.deezer_id = watchlist_artists.deezer_artist_id
                                  )
                                  OR (
                                      watchlist_artists.discogs_artist_id IS NOT NULL
                                      AND watchlist_artists.discogs_artist_id != ''
                                      AND a.discogs_id = watchlist_artists.discogs_artist_id
                                  )
                              )
                            LIMIT 1
                        ),
                        updated_at = CURRENT_TIMESTAMP
                    WHERE profile_id = ?
                      AND (musicbrainz_artist_id IS NULL OR musicbrainz_artist_id = '')
                      AND EXISTS (
                          SELECT 1
                          FROM artists a
                          WHERE a.musicbrainz_id IS NOT NULL
                            AND a.musicbrainz_id != ''
                            AND (
                                LOWER(a.name) = LOWER(watchlist_artists.artist_name)
                                OR (
                                    watchlist_artists.spotify_artist_id IS NOT NULL
                                    AND watchlist_artists.spotify_artist_id != ''
                                    AND a.spotify_artist_id = watchlist_artists.spotify_artist_id
                                )
                                OR (
                                    watchlist_artists.itunes_artist_id IS NOT NULL
                                    AND watchlist_artists.itunes_artist_id != ''
                                    AND a.itunes_artist_id = watchlist_artists.itunes_artist_id
                                )
                                OR (
                                    watchlist_artists.deezer_artist_id IS NOT NULL
                                    AND watchlist_artists.deezer_artist_id != ''
                                    AND a.deezer_id = watchlist_artists.deezer_artist_id
                                )
                                OR (
                                    watchlist_artists.discogs_artist_id IS NOT NULL
                                    AND watchlist_artists.discogs_artist_id != ''
                                    AND a.discogs_id = watchlist_artists.discogs_artist_id
                                )
                            )
                      )
                """, (profile_id,))
                conn.commit()
                if cursor.rowcount:
                    logger.info("Backfilled %s watchlist MusicBrainz artist IDs from library", cursor.rowcount)
                return cursor.rowcount
        except Exception as e:
            logger.error(f"Error backfilling watchlist MusicBrainz IDs from library: {e}")
            return 0

    def update_watchlist_artist_itunes_id(self, spotify_artist_id: str, itunes_id: str) -> bool:
        """Update the iTunes artist ID for a watchlist artist by Spotify ID (for cross-provider caching)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    UPDATE watchlist_artists
                    SET itunes_artist_id = ?, updated_at = CURRENT_TIMESTAMP
                    WHERE spotify_artist_id = ?
                """, (itunes_id, spotify_artist_id))

                conn.commit()
                if cursor.rowcount > 0:
                    logger.info(f"Cached iTunes ID {itunes_id} for Spotify artist {spotify_artist_id}")
                return cursor.rowcount > 0

        except Exception as e:
            logger.error(f"Error caching watchlist iTunes ID: {e}")
            return False

    def update_watchlist_artist_deezer_id(self, spotify_artist_id: str, deezer_id: str) -> bool:
        """Update the Deezer artist ID for a watchlist artist by Spotify ID (for cross-provider caching)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    UPDATE watchlist_artists
                    SET deezer_artist_id = ?, updated_at = CURRENT_TIMESTAMP
                    WHERE spotify_artist_id = ?
                """, (deezer_id, spotify_artist_id))

                conn.commit()
                if cursor.rowcount > 0:
                    logger.info(f"Cached Deezer ID {deezer_id} for Spotify artist {spotify_artist_id}")
                return cursor.rowcount > 0

        except Exception as e:
            logger.error(f"Error caching watchlist Deezer ID: {e}")
            return False

    # === Discovery Feature Methods ===

    def add_or_update_similar_artist(self, source_artist_id: str, similar_artist_name: str,
                                      similar_artist_spotify_id: Optional[str] = None,
                                      similar_artist_itunes_id: Optional[str] = None,
                                      similarity_rank: int = 1,
                                      profile_id: int = 1,
                                      image_url: Optional[str] = None,
                                      genres: Optional[list] = None,
                                      popularity: int = 0,
                                      similar_artist_deezer_id: Optional[str] = None,
                                      similar_artist_musicbrainz_id: Optional[str] = None) -> bool:
        """Add or update a similar artist recommendation."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                genres_json = json.dumps(genres) if genres else None

                cursor.execute("""
                    INSERT INTO similar_artists
                    (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id,
                     similar_artist_deezer_id, similar_artist_musicbrainz_id, similar_artist_name,
                     similarity_rank, occurrence_count, last_updated, profile_id,
                     image_url, genres, popularity, metadata_updated_at)
                    VALUES (?, ?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?, ?, ?, ?, CURRENT_TIMESTAMP)
                    ON CONFLICT(profile_id, source_artist_id, similar_artist_name)
                    DO UPDATE SET
                        similar_artist_spotify_id = COALESCE(excluded.similar_artist_spotify_id, similar_artist_spotify_id),
                        similar_artist_itunes_id = COALESCE(excluded.similar_artist_itunes_id, similar_artist_itunes_id),
                        similar_artist_deezer_id = COALESCE(excluded.similar_artist_deezer_id, similar_artist_deezer_id),
                        similar_artist_musicbrainz_id = COALESCE(excluded.similar_artist_musicbrainz_id, similar_artist_musicbrainz_id),
                        similarity_rank = excluded.similarity_rank,
                        occurrence_count = occurrence_count + 1,
                        last_updated = CURRENT_TIMESTAMP,
                        image_url = COALESCE(excluded.image_url, image_url),
                        genres = COALESCE(excluded.genres, genres),
                        popularity = CASE WHEN excluded.popularity > 0 THEN excluded.popularity ELSE popularity END,
                        metadata_updated_at = CASE WHEN excluded.image_url IS NOT NULL THEN CURRENT_TIMESTAMP ELSE metadata_updated_at END
                """, (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id,
                      similar_artist_deezer_id, similar_artist_musicbrainz_id, similar_artist_name,
                      similarity_rank, profile_id, image_url, genres_json, popularity))

                conn.commit()
                return True

        except Exception as e:
            logger.error(f"Error adding similar artist: {e}")
            return False

    def get_similar_artists_for_source(self, source_artist_id: str, profile_id: int = 1) -> List[SimilarArtist]:
        """Get all similar artists for a given source artist"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    SELECT * FROM similar_artists
                    WHERE source_artist_id = ? AND profile_id = ?
                    ORDER BY similarity_rank ASC
                """, (source_artist_id, profile_id))

                rows = cursor.fetchall()
                return [SimilarArtist(
                    id=row['id'],
                    source_artist_id=row['source_artist_id'],
                    similar_artist_spotify_id=row['similar_artist_spotify_id'],
                    similar_artist_itunes_id=row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None,
                    similar_artist_name=row['similar_artist_name'],
                    similarity_rank=row['similarity_rank'],
                    occurrence_count=row['occurrence_count'],
                    last_updated=datetime.fromisoformat(row['last_updated']),
                    similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None,
                    similar_artist_musicbrainz_id=row['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in row.keys() else None,
                ) for row in rows]

        except Exception as e:
            logger.error(f"Error getting similar artists: {e}")
            return []

    def get_similar_artists_missing_fallback_ids(self, source_artist_id: str, fallback_source: str = 'itunes', profile_id: int = 1) -> List[SimilarArtist]:
        """Get similar artists missing fallback-provider IDs for backfill."""
        try:
            if fallback_source not in {'itunes', 'deezer', 'musicbrainz'}:
                logger.error("Unsupported similar-artist fallback source: %s", fallback_source)
                return []

            col = {
                'deezer': 'similar_artist_deezer_id',
                'musicbrainz': 'similar_artist_musicbrainz_id',
            }.get(fallback_source, 'similar_artist_itunes_id')
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute(f"""
                    SELECT * FROM similar_artists
                    WHERE source_artist_id = ? AND profile_id = ?
                    AND ({col} IS NULL OR {col} = '')
                    ORDER BY occurrence_count DESC
                    LIMIT 50
                """, (source_artist_id, profile_id))

                rows = cursor.fetchall()
                return [SimilarArtist(
                    id=row['id'],
                    source_artist_id=row['source_artist_id'],
                    similar_artist_spotify_id=row['similar_artist_spotify_id'],
                    similar_artist_itunes_id=row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None,
                    similar_artist_name=row['similar_artist_name'],
                    similarity_rank=row['similarity_rank'],
                    occurrence_count=row['occurrence_count'],
                    last_updated=datetime.fromisoformat(row['last_updated']),
                    similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None,
                    similar_artist_musicbrainz_id=row['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in row.keys() else None,
                ) for row in rows]

        except Exception as e:
            logger.error(f"Error getting similar artists missing {fallback_source} IDs: {e}")
            return []

    def update_similar_artist_itunes_id(self, similar_artist_id: int, itunes_id: str) -> bool:
        """Update a similar artist's iTunes ID (for backfill)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    UPDATE similar_artists
                    SET similar_artist_itunes_id = ?
                    WHERE id = ?
                """, (itunes_id, similar_artist_id))

                conn.commit()
                return cursor.rowcount > 0

        except Exception as e:
            logger.error(f"Error updating similar artist iTunes ID: {e}")
            return False

    def update_similar_artist_deezer_id(self, similar_artist_id: int, deezer_id: str) -> bool:
        """Update a similar artist's Deezer ID (for backfill)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    UPDATE similar_artists
                    SET similar_artist_deezer_id = ?
                    WHERE id = ?
                """, (deezer_id, similar_artist_id))

                conn.commit()
                return cursor.rowcount > 0

        except Exception as e:
            logger.error(f"Error updating similar artist Deezer ID: {e}")
            return False

    def update_similar_artist_musicbrainz_id(self, similar_artist_id: int, musicbrainz_id: str) -> bool:
        """Update a similar artist's MusicBrainz ID (for backfill)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    UPDATE similar_artists
                    SET similar_artist_musicbrainz_id = ?
                    WHERE id = ?
                """, (musicbrainz_id, similar_artist_id))

                conn.commit()
                return cursor.rowcount > 0

        except Exception as e:
            logger.error(f"Error updating similar artist MusicBrainz ID: {e}")
            return False

    def update_similar_artist_metadata(self, similar_artist_id: int, image_url: str = None,
                                        genres: list = None, popularity: int = None) -> bool:
        """Cache artist metadata (image, genres, popularity) to avoid repeated API calls"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                genres_json = json.dumps(genres) if genres else None
                cursor.execute("""
                    UPDATE similar_artists
                    SET image_url = ?, genres = ?, popularity = ?, metadata_updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                """, (image_url, genres_json, popularity or 0, similar_artist_id))
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error updating similar artist metadata: {e}")
            return False

    def update_similar_artist_metadata_by_external_id(self, external_id: str, source: str = 'spotify',
                                                       image_url: str = None, genres: list = None,
                                                       popularity: int = None) -> bool:
        """Cache artist metadata by external source ID (updates all rows for that artist)."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                genres_json = json.dumps(genres) if genres else None
                if source == 'spotify':
                    where_clause = "similar_artist_spotify_id = ?"
                elif source == 'deezer':
                    where_clause = "similar_artist_deezer_id = ?"
                elif source == 'musicbrainz':
                    where_clause = "similar_artist_musicbrainz_id = ?"
                else:
                    where_clause = "similar_artist_itunes_id = ?"
                cursor.execute(f"""
                    UPDATE similar_artists
                    SET image_url = ?, genres = ?, popularity = ?, metadata_updated_at = CURRENT_TIMESTAMP
                    WHERE {where_clause}
                """, (image_url, genres_json, popularity or 0, external_id))
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error updating similar artist metadata by external ID: {e}")
            return False

    def has_fresh_similar_artists(self, source_artist_id: str, days_threshold: int = 30, profile_id: int = 1) -> bool:
        """
        Check if we have cached similar artists that are still fresh (<days_threshold old).

        Args:
            source_artist_id: The source artist ID to check
            days_threshold: Maximum age in days to consider fresh
            profile_id: Profile to check freshness for

        Returns True if we have recent data, False if data is stale or missing.
        """
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    SELECT COUNT(*) as count, MAX(last_updated) as last_updated
                    FROM similar_artists
                    WHERE source_artist_id = ? AND profile_id = ?
                """, (source_artist_id, profile_id))

                row = cursor.fetchone()

                if not row or row['count'] == 0:
                    # No similar artists cached
                    return False

                # Check if data is fresh
                last_updated = datetime.fromisoformat(row['last_updated'])
                days_since_update = (datetime.now() - last_updated).total_seconds() / 86400  # seconds to days

                if days_since_update >= days_threshold:
                    return False

                return True

        except Exception as e:
            logger.error(f"Error checking similar artists freshness: {e}")
            return False  # Default to re-fetching on error

    def get_top_similar_artists(
        self,
        limit: int = 50,
        profile_id: int = 1,
        require_source: str = None,
        exclude_library_server: str = None,
    ) -> List[SimilarArtist]:
        """Get top similar artists excluding watchlist artists, with cycling support.
        require_source: if set, only returns artists with that source ID.
        exclude_library_server: if set, also excludes artists already present in that media server."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Build source filter
                source_filter = ''
                if require_source == 'spotify':
                    source_filter = "AND sa.similar_artist_spotify_id IS NOT NULL AND sa.similar_artist_spotify_id != ''"
                elif require_source == 'itunes':
                    source_filter = "AND sa.similar_artist_itunes_id IS NOT NULL AND sa.similar_artist_itunes_id != ''"
                elif require_source == 'deezer':
                    source_filter = "AND sa.similar_artist_deezer_id IS NOT NULL AND sa.similar_artist_deezer_id != ''"
                elif require_source == 'musicbrainz':
                    source_filter = "AND sa.similar_artist_musicbrainz_id IS NOT NULL AND sa.similar_artist_musicbrainz_id != ''"

                library_artist_keys = None
                sql_limit = limit
                if exclude_library_server:
                    cursor.execute("""
                        SELECT name, spotify_artist_id, itunes_artist_id, deezer_id, musicbrainz_id
                        FROM artists
                        WHERE server_source = ?
                    """, (exclude_library_server,))
                    library_rows = cursor.fetchall()
                    library_artist_keys = {
                        'spotify': {r['spotify_artist_id'] for r in library_rows if r['spotify_artist_id']},
                        'itunes': {r['itunes_artist_id'] for r in library_rows if r['itunes_artist_id']},
                        'deezer': {r['deezer_id'] for r in library_rows if r['deezer_id']},
                        'musicbrainz': {r['musicbrainz_id'] for r in library_rows if r['musicbrainz_id']},
                        'names': {
                            self._normalize_for_comparison(r['name'])
                            for r in library_rows
                            if r['name']
                        },
                    }
                    sql_limit = max(limit * 5, limit + 100)

                cursor.execute(f"""
                    SELECT
                        MAX(sa.id) as id,
                        MAX(sa.source_artist_id) as source_artist_id,
                        MAX(sa.similar_artist_spotify_id) as similar_artist_spotify_id,
                        MAX(sa.similar_artist_itunes_id) as similar_artist_itunes_id,
                        MAX(sa.similar_artist_deezer_id) as similar_artist_deezer_id,
                        MAX(sa.similar_artist_musicbrainz_id) as similar_artist_musicbrainz_id,
                        sa.similar_artist_name,
                        AVG(sa.similarity_rank) as similarity_rank,
                        SUM(sa.occurrence_count) as occurrence_count,
                        MAX(sa.last_updated) as last_updated,
                        MAX(sa.image_url) as image_url,
                        MAX(sa.genres) as genres,
                        MAX(sa.popularity) as popularity
                    FROM similar_artists sa
                    LEFT JOIN watchlist_artists wa ON (
                        (sa.similar_artist_spotify_id IS NOT NULL AND sa.similar_artist_spotify_id = wa.spotify_artist_id)
                        OR (sa.similar_artist_itunes_id IS NOT NULL AND sa.similar_artist_itunes_id = wa.itunes_artist_id)
                        OR (sa.similar_artist_deezer_id IS NOT NULL AND sa.similar_artist_deezer_id = wa.deezer_artist_id)
                        OR LOWER(sa.similar_artist_name) = LOWER(wa.artist_name)
                    ) AND wa.profile_id = ?
                    WHERE wa.id IS NULL AND sa.profile_id = ? {source_filter}
                    GROUP BY sa.similar_artist_name
                    ORDER BY
                        CASE WHEN MAX(sa.last_featured) IS NULL THEN 0 ELSE 1 END,
                        MAX(sa.last_featured) ASC,
                        occurrence_count DESC,
                        similarity_rank ASC
                    LIMIT ?
                """, (profile_id, profile_id, sql_limit))

                rows = cursor.fetchall()
                results = []
                for row in rows:
                    if library_artist_keys:
                        spotify_id = row['similar_artist_spotify_id']
                        itunes_id = row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None
                        deezer_id = row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None
                        musicbrainz_id = row['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in row.keys() else None
                        normalized_name = self._normalize_for_comparison(row['similar_artist_name'])
                        if (
                            (spotify_id and spotify_id in library_artist_keys['spotify'])
                            or (itunes_id and itunes_id in library_artist_keys['itunes'])
                            or (deezer_id and deezer_id in library_artist_keys['deezer'])
                            or (musicbrainz_id and musicbrainz_id in library_artist_keys['musicbrainz'])
                            or (normalized_name and normalized_name in library_artist_keys['names'])
                        ):
                            continue

                    genres_raw = row['genres'] if 'genres' in row.keys() else None
                    try:
                        genres_list = json.loads(genres_raw) if genres_raw else None
                    except (json.JSONDecodeError, TypeError):
                        genres_list = None
                    results.append(SimilarArtist(
                        id=row['id'],
                        source_artist_id=row['source_artist_id'],
                        similar_artist_spotify_id=row['similar_artist_spotify_id'],
                        similar_artist_itunes_id=row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None,
                        similar_artist_deezer_id=row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None,
                        similar_artist_musicbrainz_id=row['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in row.keys() else None,
                        similar_artist_name=row['similar_artist_name'],
                        similarity_rank=int(row['similarity_rank']),
                        occurrence_count=row['occurrence_count'],
                        last_updated=datetime.fromisoformat(row['last_updated']),
                        image_url=row['image_url'] if 'image_url' in row.keys() else None,
                        genres=genres_list,
                        popularity=row['popularity'] if 'popularity' in row.keys() else 0,
                    ))
                    if len(results) >= limit:
                        break
                return results

        except Exception as e:
            logger.error(f"Error getting top similar artists: {e}")
            return []

    def mark_artists_featured(self, artist_names: List[str]):
        """Update last_featured timestamp for artists shown in the hero slider"""
        if not artist_names:
            return
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                placeholders = ','.join('?' for _ in artist_names)
                cursor.execute(f"""
                    UPDATE similar_artists
                    SET last_featured = CURRENT_TIMESTAMP
                    WHERE similar_artist_name IN ({placeholders})
                """, artist_names)
                conn.commit()
        except Exception as e:
            logger.error(f"Error marking artists as featured: {e}")

    def add_to_discovery_pool(self, track_data: Dict[str, Any], source: str = 'spotify', profile_id: int = 1) -> bool:
        """Add a track to the discovery pool (supports Spotify, iTunes, and Deezer sources)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Check if track already exists based on source (scoped to profile)
                if source == 'spotify' and track_data.get('spotify_track_id'):
                    cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE spotify_track_id = ? AND source = 'spotify' AND profile_id = ?",
                                  (track_data['spotify_track_id'], profile_id))
                elif source == 'itunes' and track_data.get('itunes_track_id'):
                    cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE itunes_track_id = ? AND source = 'itunes' AND profile_id = ?",
                                  (track_data['itunes_track_id'], profile_id))
                elif source == 'deezer' and track_data.get('deezer_track_id'):
                    cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE deezer_track_id = ? AND source = 'deezer' AND profile_id = ?",
                                  (track_data['deezer_track_id'], profile_id))
                else:
                    # Fallback check by track name and artist
                    cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE track_name = ? AND artist_name = ? AND source = ? AND profile_id = ?",
                                  (track_data['track_name'], track_data['artist_name'], source, profile_id))

                if cursor.fetchone()['count'] > 0:
                    return True  # Already in pool

                # Get artist genres if available
                artist_genres = track_data.get('artist_genres')
                artist_genres_json = json.dumps(artist_genres) if artist_genres else None

                cursor.execute("""
                    INSERT INTO discovery_pool
                    (spotify_track_id, spotify_album_id, spotify_artist_id,
                     itunes_track_id, itunes_album_id, itunes_artist_id,
                     deezer_track_id, deezer_album_id, deezer_artist_id,
                     source, track_name, artist_name, album_name, album_cover_url,
                     duration_ms, popularity, release_date, is_new_release, track_data_json, artist_genres, added_date, profile_id)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
                """, (
                    track_data.get('spotify_track_id'),
                    track_data.get('spotify_album_id'),
                    track_data.get('spotify_artist_id'),
                    track_data.get('itunes_track_id'),
                    track_data.get('itunes_album_id'),
                    track_data.get('itunes_artist_id'),
                    track_data.get('deezer_track_id'),
                    track_data.get('deezer_album_id'),
                    track_data.get('deezer_artist_id'),
                    source,
                    track_data['track_name'],
                    track_data['artist_name'],
                    track_data['album_name'],
                    track_data.get('album_cover_url'),
                    track_data['duration_ms'],
                    track_data.get('popularity', 0),
                    track_data['release_date'],
                    track_data.get('is_new_release', False),
                    json.dumps(track_data['track_data_json']),
                    artist_genres_json,
                    profile_id
                ))

                conn.commit()
                return True

        except Exception as e:
            logger.error(f"Error adding to discovery pool: {e}")
            return False

    def rotate_discovery_pool(self, max_tracks: int = 2000, remove_count: int = 500, profile_id: int = 1):
        """Remove oldest tracks from discovery pool if it exceeds max_tracks"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Check current count for this profile
                cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE profile_id = ?", (profile_id,))
                current_count = cursor.fetchone()['count']

                if current_count > max_tracks:
                    # Remove oldest tracks for this profile
                    cursor.execute("""
                        DELETE FROM discovery_pool
                        WHERE id IN (
                            SELECT id FROM discovery_pool
                            WHERE profile_id = ?
                            ORDER BY added_date ASC
                            LIMIT ?
                        )
                    """, (profile_id, remove_count))

                    conn.commit()
                    logger.info(f"Removed {remove_count} oldest tracks from discovery pool")

        except Exception as e:
            logger.error(f"Error rotating discovery pool: {e}")

    def get_discovery_pool_tracks(self, limit: int = 100, new_releases_only: bool = False, source: Optional[str] = None, profile_id: int = 1) -> List[DiscoveryTrack]:
        """Get tracks from discovery pool, optionally filtered by source ('spotify', 'itunes', or 'deezer')"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Build query with optional source filter
                where_clauses = ["profile_id = ?"]
                params = [profile_id]

                if new_releases_only:
                    where_clauses.append("is_new_release = 1")

                if source:
                    where_clauses.append("source = ?")
                    params.append(source)

                where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
                params.append(limit)

                cursor.execute(f"""
                    SELECT * FROM discovery_pool
                    {where_sql}
                    ORDER BY added_date DESC
                    LIMIT ?
                """, params)

                rows = cursor.fetchall()
                row_keys = rows[0].keys() if rows else []

                return [DiscoveryTrack(
                    id=row['id'],
                    spotify_track_id=row['spotify_track_id'],
                    spotify_album_id=row['spotify_album_id'],
                    spotify_artist_id=row['spotify_artist_id'],
                    itunes_track_id=row['itunes_track_id'] if 'itunes_track_id' in row_keys else None,
                    itunes_album_id=row['itunes_album_id'] if 'itunes_album_id' in row_keys else None,
                    itunes_artist_id=row['itunes_artist_id'] if 'itunes_artist_id' in row_keys else None,
                    deezer_track_id=row['deezer_track_id'] if 'deezer_track_id' in row_keys else None,
                    deezer_album_id=row['deezer_album_id'] if 'deezer_album_id' in row_keys else None,
                    deezer_artist_id=row['deezer_artist_id'] if 'deezer_artist_id' in row_keys else None,
                    source=row['source'] if 'source' in row_keys else 'spotify',
                    track_name=row['track_name'],
                    artist_name=row['artist_name'],
                    album_name=row['album_name'],
                    album_cover_url=row['album_cover_url'],
                    duration_ms=row['duration_ms'],
                    popularity=row['popularity'],
                    release_date=row['release_date'],
                    is_new_release=bool(row['is_new_release']),
                    track_data_json=row['track_data_json'],
                    added_date=datetime.fromisoformat(row['added_date'])
                ) for row in rows]

        except Exception as e:
            logger.error(f"Error getting discovery pool tracks: {e}")
            return []

    def cache_discovery_recent_album(self, album_data: Dict[str, Any], source: str = 'spotify', profile_id: int = 1) -> bool:
        """Cache a recent album for the discover page (supports Spotify, iTunes, and Deezer sources)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    INSERT OR REPLACE INTO discovery_recent_albums
                    (album_spotify_id, album_itunes_id, album_deezer_id,
                     artist_spotify_id, artist_itunes_id, artist_deezer_id, source,
                     album_name, artist_name, album_cover_url, release_date, album_type, cached_date, profile_id)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
                """, (
                    album_data.get('album_spotify_id'),
                    album_data.get('album_itunes_id'),
                    album_data.get('album_deezer_id'),
                    album_data.get('artist_spotify_id'),
                    album_data.get('artist_itunes_id'),
                    album_data.get('artist_deezer_id'),
                    source,
                    album_data['album_name'],
                    album_data['artist_name'],
                    album_data.get('album_cover_url'),
                    album_data['release_date'],
                    album_data.get('album_type', 'album'),
                    profile_id
                ))

                conn.commit()
                return True

        except Exception as e:
            logger.error(f"Error caching discovery recent album: {e}")
            return False

    def get_discovery_recent_albums(self, limit: int = 10, source: Optional[str] = None, profile_id: int = 1) -> List[Dict[str, Any]]:
        """Get cached recent albums for discover page, optionally filtered by source"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                if source:
                    cursor.execute("""
                        SELECT * FROM discovery_recent_albums
                        WHERE source = ? AND profile_id = ?
                        ORDER BY release_date DESC
                        LIMIT ?
                    """, (source, profile_id, limit))
                else:
                    cursor.execute("""
                        SELECT * FROM discovery_recent_albums
                        WHERE profile_id = ?
                        ORDER BY release_date DESC
                        LIMIT ?
                    """, (profile_id, limit))

                rows = cursor.fetchall()
                row_keys = rows[0].keys() if rows else []

                return [{
                    'album_spotify_id': row['album_spotify_id'],
                    'album_itunes_id': row['album_itunes_id'] if 'album_itunes_id' in row_keys else None,
                    'album_deezer_id': row['album_deezer_id'] if 'album_deezer_id' in row_keys else None,
                    'album_name': row['album_name'],
                    'artist_name': row['artist_name'],
                    'artist_spotify_id': row['artist_spotify_id'],
                    'artist_itunes_id': row['artist_itunes_id'] if 'artist_itunes_id' in row_keys else None,
                    'artist_deezer_id': row['artist_deezer_id'] if 'artist_deezer_id' in row_keys else None,
                    'album_cover_url': row['album_cover_url'],
                    'release_date': row['release_date'],
                    'album_type': row['album_type'],
                    'source': row['source'] if 'source' in row_keys else 'spotify'
                } for row in rows]

        except Exception as e:
            logger.error(f"Error getting discovery recent albums: {e}")
            return []

    def update_discovery_recent_album_cover(self, album_id: str, cover_url: str) -> bool:
        """Backfill a missing cover URL on a recent album entry."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    UPDATE discovery_recent_albums SET album_cover_url = ?
                    WHERE album_spotify_id = ? OR album_itunes_id = ? OR album_deezer_id = ?
                """, (cover_url, album_id, album_id, album_id))
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.debug(f"Error updating recent album cover: {e}")
            return False

    def clear_discovery_recent_albums(self, profile_id: int = 1) -> bool:
        """Clear cached recent albums for a profile"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("DELETE FROM discovery_recent_albums WHERE profile_id = ?", (profile_id,))
                conn.commit()
                return True
        except Exception as e:
            logger.error(f"Error clearing discovery recent albums: {e}")
            return False

    def save_curated_playlist(self, playlist_type: str, track_ids: List[str], profile_id: int = 1) -> bool:
        """Save a curated playlist selection (stays same until next discovery pool update)"""
        try:
            import json
            with self._get_connection() as conn:
                cursor = conn.cursor()
                # Delete existing for this profile+type, then insert
                cursor.execute("DELETE FROM discovery_curated_playlists WHERE playlist_type = ? AND profile_id = ?",
                               (playlist_type, profile_id))
                cursor.execute("""
                    INSERT INTO discovery_curated_playlists
                    (playlist_type, track_ids_json, curated_date, profile_id)
                    VALUES (?, ?, CURRENT_TIMESTAMP, ?)
                """, (playlist_type, json.dumps(track_ids), profile_id))
                conn.commit()
                return True
        except Exception as e:
            logger.error(f"Error saving curated playlist {playlist_type}: {e}")
            return False

    def get_curated_playlist(self, playlist_type: str, profile_id: int = 1) -> Optional[List[str]]:
        """Get saved curated playlist track IDs for the given profile"""
        try:
            import json
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT track_ids_json FROM discovery_curated_playlists
                    WHERE playlist_type = ? AND profile_id = ?
                """, (playlist_type, profile_id))
                row = cursor.fetchone()
                if row:
                    return json.loads(row['track_ids_json'])
                return None
        except Exception as e:
            logger.error(f"Error getting curated playlist {playlist_type}: {e}")
            return None

    def should_populate_discovery_pool(self, hours_threshold: int = 24, profile_id: int = 1) -> bool:
        """Check if discovery pool should be populated (hasn't been updated in X hours)"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT last_populated_timestamp
                    FROM discovery_pool_metadata
                    WHERE profile_id = ?
                """, (profile_id,))
                row = cursor.fetchone()

                if not row:
                    # Never populated before
                    return True

                last_populated = datetime.fromisoformat(row['last_populated_timestamp'])
                hours_since_update = (datetime.now() - last_populated).total_seconds() / 3600

                return hours_since_update >= hours_threshold

        except Exception as e:
            logger.error(f"Error checking discovery pool timestamp: {e}")
            return True  # Default to allowing population on error

    def update_discovery_pool_timestamp(self, track_count: int, profile_id: int = 1) -> bool:
        """Update the last populated timestamp and track count"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    INSERT INTO discovery_pool_metadata
                    (profile_id, last_populated_timestamp, track_count, updated_at)
                    VALUES (?, ?, ?, CURRENT_TIMESTAMP)
                    ON CONFLICT(profile_id) DO UPDATE SET
                        last_populated_timestamp = excluded.last_populated_timestamp,
                        track_count = excluded.track_count,
                        updated_at = CURRENT_TIMESTAMP
                """, (profile_id, datetime.now().isoformat(), track_count))
                conn.commit()
                return True
        except Exception as e:
            logger.error(f"Error updating discovery pool timestamp: {e}")
            return False

    def cleanup_old_discovery_tracks(self, days_threshold: int = 365) -> int:
        """Remove tracks from discovery pool older than X days. Returns count of deleted tracks."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Delete tracks older than threshold
                cursor.execute("""
                    DELETE FROM discovery_pool
                    WHERE added_date < datetime('now', '-' || ? || ' days')
                """, (days_threshold,))

                deleted_count = cursor.rowcount
                conn.commit()

                if deleted_count > 0:
                    logger.info(f"Cleaned up {deleted_count} discovery tracks older than {days_threshold} days")

                return deleted_count

        except Exception as e:
            logger.error(f"Error cleaning up old discovery tracks: {e}")
            return 0

    def add_recent_release(self, watchlist_artist_id: int, album_data: Dict[str, Any], profile_id: int = 1) -> bool:
        """Add a recent release to the recent_releases table"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    INSERT OR IGNORE INTO recent_releases
                    (watchlist_artist_id, album_spotify_id, album_name, release_date, album_cover_url, track_count, added_date, profile_id)
                    VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
                """, (
                    watchlist_artist_id,
                    album_data['album_spotify_id'],
                    album_data['album_name'],
                    album_data['release_date'],
                    album_data.get('album_cover_url'),
                    album_data.get('track_count', 0),
                    profile_id
                ))

                conn.commit()
                return True

        except Exception as e:
            logger.error(f"Error adding recent release: {e}")
            return False

    def get_recent_releases(self, limit: int = 50, profile_id: int = 1) -> List[RecentRelease]:
        """Get recent releases from watchlist artists for the given profile"""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    SELECT * FROM recent_releases
                    WHERE profile_id = ?
                    ORDER BY release_date DESC, added_date DESC
                    LIMIT ?
                """, (profile_id, limit))

                rows = cursor.fetchall()
                return [RecentRelease(
                    id=row['id'],
                    watchlist_artist_id=row['watchlist_artist_id'],
                    album_spotify_id=row['album_spotify_id'],
                    album_itunes_id=row['album_itunes_id'] if 'album_itunes_id' in row.keys() else None,
                    album_deezer_id=row['album_deezer_id'] if 'album_deezer_id' in row.keys() else None,
                    source=row['source'] if 'source' in row.keys() else 'spotify',
                    album_name=row['album_name'],
                    release_date=row['release_date'],
                    album_cover_url=row['album_cover_url'],
                    track_count=row['track_count'],
                    added_date=datetime.fromisoformat(row['added_date'])
                ) for row in rows]

        except Exception as e:
            logger.error(f"Error getting recent releases: {e}")
            return []

    def get_database_info(self) -> Dict[str, Any]:
        """Get comprehensive database information for all servers (legacy method)"""
        try:
            stats = self.get_statistics()
            
            # Get database file size
            db_size = self.database_path.stat().st_size if self.database_path.exists() else 0
            db_size_mb = db_size / (1024 * 1024)
            
            # Get last update time (most recent updated_at timestamp)
            conn = self._get_connection()
            cursor = conn.cursor()
            
            cursor.execute("""
                SELECT MAX(updated_at) as last_update 
                FROM (
                    SELECT updated_at FROM artists
                    UNION ALL
                    SELECT updated_at FROM albums
                    UNION ALL
                    SELECT updated_at FROM tracks
                )
            """)
            
            result = cursor.fetchone()
            last_update = result['last_update'] if result and result['last_update'] else None
            
            # Get last full refresh
            last_full_refresh = self.get_last_full_refresh()
            
            return {
                **stats,
                'database_size_mb': round(db_size_mb, 2),
                'database_path': str(self.database_path),
                'last_update': last_update,
                'last_full_refresh': last_full_refresh
            }
            
        except Exception as e:
            logger.error(f"Error getting database info: {e}")
            return {
                'artists': 0,
                'albums': 0,
                'tracks': 0,
                'database_size_mb': 0.0,
                'database_path': str(self.database_path),
                'last_update': None,
                'last_full_refresh': None
            }
    
    def get_database_info_for_server(self, server_source: str = None) -> Dict[str, Any]:
        """Get comprehensive database information filtered by server source"""
        try:
            # Import here to avoid circular imports
            from config.settings import config_manager
            
            # If no server specified, use active server
            if server_source is None:
                server_source = config_manager.get_active_media_server()
            
            stats = self.get_statistics_for_server(server_source)
            
            # Get database file size (always total, not server-specific)
            db_size = self.database_path.stat().st_size if self.database_path.exists() else 0
            db_size_mb = db_size / (1024 * 1024)
            
            # Get last update time for this server
            conn = self._get_connection()
            cursor = conn.cursor()
            
            cursor.execute("""
                SELECT MAX(updated_at) as last_update 
                FROM (
                    SELECT updated_at FROM artists WHERE server_source = ?
                    UNION ALL
                    SELECT updated_at FROM albums WHERE server_source = ?
                    UNION ALL
                    SELECT updated_at FROM tracks WHERE server_source = ?
                )
            """, (server_source, server_source, server_source))
            
            result = cursor.fetchone()
            last_update = result['last_update'] if result and result['last_update'] else None
            
            # Get last full refresh (global setting, not server-specific)
            last_full_refresh = self.get_last_full_refresh()
            
            return {
                **stats,
                'database_size_mb': round(db_size_mb, 2),
                'database_path': str(self.database_path),
                'last_update': last_update,
                'last_full_refresh': last_full_refresh,
                'server_source': server_source
            }
            
        except Exception as e:
            logger.error(f"Error getting database info for {server_source}: {e}")
            return {
                'artists': 0,
                'albums': 0,
                'tracks': 0,
                'database_size_mb': 0.0,
                'database_path': str(self.database_path),
                'last_update': None,
                'last_full_refresh': None,
                'server_source': server_source
            }

    def get_library_artists(self, search_query: str = "", letter: str = "", page: int = 1, limit: int = 50, watchlist_filter: str = "all", profile_id: int = 1, source_filter: str = "") -> Dict[str, Any]:
        """
        Get artists for the library page with search, filtering, and pagination

        Args:
            search_query: Search term to filter artists by name
            letter: Filter by first letter (a-z, #, or "" for all)
            page: Page number (1-based)
            limit: Number of results per page
            watchlist_filter: Filter by watchlist status ("all", "watched", "unwatched")
            source_filter: Filter by metadata source match (e.g. "spotify", "!spotify" for unmatched)

        Returns:
            Dict containing artists list, pagination info, and total count
        """
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Build WHERE clause
                where_conditions = []
                params = []

                if search_query:
                    where_conditions.append("LOWER(name) LIKE LOWER(?)")
                    params.append(f"%{search_query}%")

                if letter and letter != "all":
                    if letter == "#":
                        # Numbers and special characters
                        where_conditions.append("SUBSTR(UPPER(name), 1, 1) NOT GLOB '[A-Z]'")
                    else:
                        # Specific letter
                        where_conditions.append("UPPER(SUBSTR(name, 1, 1)) = UPPER(?)")
                        params.append(letter)

                # Metadata source filter — match or exclude by enrichment source
                if source_filter:
                    _source_columns = {
                        'spotify': 'a.spotify_artist_id',
                        'musicbrainz': 'a.musicbrainz_id',
                        'deezer': 'a.deezer_id',
                        'discogs': 'a.discogs_id',
                        'audiodb': 'a.audiodb_id',
                        'itunes': 'a.itunes_artist_id',
                        'lastfm': 'a.lastfm_url',
                        'genius': 'a.genius_url',
                        'tidal': 'a.tidal_id',
                        'qobuz': 'a.qobuz_id',
                    }
                    negate = source_filter.startswith('!')
                    key = source_filter.lstrip('!')
                    col = _source_columns.get(key)
                    if col:
                        if negate:
                            where_conditions.append(f"({col} IS NULL OR {col} = '')")
                        else:
                            where_conditions.append(f"({col} IS NOT NULL AND {col} != '')")

                # Get active server for filtering
                from config.settings import config_manager
                active_server = config_manager.get_active_media_server()

                # Add active server filter to where conditions
                where_conditions.append("a.server_source = ?")
                params.append(active_server)

                where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"

                # Pre-fetch watchlist data for this profile (small table, single fast query)
                cursor.execute("SELECT spotify_artist_id, itunes_artist_id, LOWER(artist_name) as name_lower FROM watchlist_artists WHERE profile_id = ?", (profile_id,))
                watchlist_rows = cursor.fetchall()
                wl_spotify = {r['spotify_artist_id'] for r in watchlist_rows if r['spotify_artist_id']}
                wl_itunes = {r['itunes_artist_id'] for r in watchlist_rows if r['itunes_artist_id']}
                wl_names = {r['name_lower'] for r in watchlist_rows if r['name_lower']}

                # Apply watchlist filter as WHERE conditions using IN clauses
                if watchlist_filter in ("watched", "unwatched"):
                    match_parts = []
                    match_params = []
                    if wl_spotify:
                        match_parts.append(f"(a.spotify_artist_id IS NOT NULL AND a.spotify_artist_id IN ({','.join('?' * len(wl_spotify))}))")
                        match_params.extend(wl_spotify)
                    if wl_itunes:
                        match_parts.append(f"(a.itunes_artist_id IS NOT NULL AND a.itunes_artist_id IN ({','.join('?' * len(wl_itunes))}))")
                        match_params.extend(wl_itunes)
                    if wl_names:
                        match_parts.append(f"LOWER(a.name) IN ({','.join('?' * len(wl_names))})")
                        match_params.extend(wl_names)

                    if match_parts:
                        combined = ' OR '.join(match_parts)
                        if watchlist_filter == "watched":
                            where_clause += f" AND ({combined})"
                        else:
                            where_clause += f" AND NOT ({combined})"
                        params.extend(match_params)
                    elif watchlist_filter == "watched":
                        # Empty watchlist, no artists can match
                        where_clause += " AND 0"

                # Step 1: Fast count query — no joins, just filter canonical artists
                count_query = f"""
                    SELECT COUNT(*) as total_count
                    FROM artists a
                    WHERE {where_clause}
                        AND a.id = (SELECT MIN(a2.id) FROM artists a2
                                    WHERE a2.name = a.name AND a2.server_source = a.server_source)
                """
                cursor.execute(count_query, params)
                total_count = cursor.fetchone()['total_count']

                # Step 2: Get paginated artist rows (no album/track joins — fast)
                offset = (page - 1) * limit
                artists_query = f"""
                    SELECT
                        a.id,
                        a.name,
                        a.thumb_url,
                        a.genres,
                        a.musicbrainz_id,
                        a.spotify_artist_id,
                        a.itunes_artist_id,
                        a.deezer_id,
                        a.audiodb_id,
                        a.discogs_id,
                        a.lastfm_url,
                        a.genius_url,
                        a.tidal_id,
                        a.qobuz_id,
                        a.soul_id,
                        a.amazon_id,
                        a.server_source
                    FROM artists a
                    WHERE {where_clause}
                        AND a.id = (SELECT MIN(a2.id) FROM artists a2
                                    WHERE a2.name = a.name AND a2.server_source = a.server_source)
                    ORDER BY a.name COLLATE NOCASE
                    LIMIT ? OFFSET ?
                """
                query_params = params + [limit, offset]
                cursor.execute(artists_query, query_params)
                artist_rows = cursor.fetchall()

                # Step 3: Batch-fetch album/track counts only for the 75 artists on this page
                artist_ids_on_page = [row['id'] for row in artist_rows]
                counts_map = {}
                if artist_ids_on_page:
                    # Get all artist IDs that share names with the page artists (for dedup merging)
                    name_pairs = [(row['name'], row['server_source']) for row in artist_rows]
                    # Build counts query using artist IDs directly
                    # Get all artist IDs sharing names with page artists
                    id_placeholders = ','.join(['?'] * len(artist_ids_on_page))
                    cursor.execute(f"""
                        SELECT id, name, server_source FROM artists
                        WHERE id IN ({id_placeholders})
                    """, artist_ids_on_page)
                    page_info = cursor.fetchall()

                    # Find all related artist IDs (same name+server) for count merging
                    or_clauses = []
                    or_params = []
                    for pi in page_info:
                        or_clauses.append("(ar.name = ? AND ar.server_source = ?)")
                        or_params.extend([pi['name'], pi['server_source']])

                    cursor.execute(f"""
                        SELECT
                            ar.name as artist_name, ar.server_source as artist_source,
                            COUNT(DISTINCT al.id) as album_count,
                            COUNT(DISTINCT t.id) as track_count
                        FROM artists ar
                        LEFT JOIN albums al ON al.artist_id = ar.id
                        LEFT JOIN tracks t ON t.album_id = al.id
                        WHERE {' OR '.join(or_clauses)}
                        GROUP BY ar.name, ar.server_source
                    """, or_params)
                    # Map back to canonical IDs
                    name_to_canonical = {(pi['name'], pi['server_source']): pi['id'] for pi in page_info}
                    for crow in cursor.fetchall():
                        cid = name_to_canonical.get((crow['artist_name'], crow['artist_source']))
                        if cid:
                            counts_map[cid] = (crow['album_count'], crow['track_count'])

                rows = artist_rows

                # Convert to artist objects
                artists = []
                for row in rows:
                    # Parse genres from GROUP_CONCAT result
                    genres_str = row['genres'] or ''
                    genres = []
                    if genres_str:
                        # Split by comma and clean up duplicates
                        genre_set = set()
                        for genre in genres_str.split(','):
                            if genre and genre.strip():
                                genre_set.update(g.strip() for g in genre.split(',') if g.strip())
                        genres = list(genre_set)

                    artist = DatabaseArtist(
                        id=row['id'],
                        name=row['name'],
                        thumb_url=row['thumb_url'] if row['thumb_url'] else None,
                        genres=genres
                    )

                    # Determine watchlist status via set lookups
                    is_watched = (
                        (row['spotify_artist_id'] and row['spotify_artist_id'] in wl_spotify)
                        or (row['itunes_artist_id'] and row['itunes_artist_id'] in wl_itunes)
                        or (row['name'] and row['name'].lower() in wl_names)
                    )

                    # Add stats
                    artist_data = {
                        'id': artist.id,
                        'name': artist.name,
                        'image_url': artist.thumb_url,
                        'genres': artist.genres,
                        'musicbrainz_id': row['musicbrainz_id'],
                        'spotify_artist_id': row['spotify_artist_id'],
                        'itunes_artist_id': row['itunes_artist_id'],
                        'deezer_id': row['deezer_id'],
                        'audiodb_id': row['audiodb_id'],
                        'discogs_id': row['discogs_id'],
                        'lastfm_url': row['lastfm_url'],
                        'genius_url': row['genius_url'],
                        'tidal_id': row['tidal_id'],
                        'qobuz_id': row['qobuz_id'],
                        'soul_id': row['soul_id'],
                        'amazon_id': row['amazon_id'],
                        'album_count': counts_map.get(row['id'], (0, 0))[0],
                        'track_count': counts_map.get(row['id'], (0, 0))[1],
                        'is_watched': bool(is_watched)
                    }
                    artists.append(artist_data)

                # Calculate pagination info
                total_pages = (total_count + limit - 1) // limit
                has_prev = page > 1
                has_next = page < total_pages

                return {
                    'artists': artists,
                    'pagination': {
                        'page': page,
                        'limit': limit,
                        'total_count': total_count,
                        'total_pages': total_pages,
                        'has_prev': has_prev,
                        'has_next': has_next
                    }
                }

        except Exception as e:
            logger.error(f"Error getting library artists: {e}")
            return {
                'artists': [],
                'pagination': {
                    'page': 1,
                    'limit': limit,
                    'total_count': 0,
                    'total_pages': 0,
                    'has_prev': False,
                    'has_next': False
                }
            }

    def get_artist_discography(self, artist_id) -> Dict[str, Any]:
        """
        Get complete artist information and their releases from the database.
        This will be combined with Spotify data for the full discography view.

        Args:
            artist_id: The artist ID from the database (string or int)

        Returns:
            Dict containing artist info and their owned releases
        """
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Get artist information
                cursor.execute("""
                    SELECT
                        id, name, thumb_url, genres, server_source,
                        musicbrainz_id, deezer_id, audiodb_id, discogs_id,
                        spotify_artist_id, itunes_artist_id, lastfm_url, genius_url,
                        tidal_id, qobuz_id, soul_id, amazon_id,
                        lastfm_listeners, lastfm_playcount, lastfm_tags, lastfm_bio
                    FROM artists
                    WHERE id = ?
                """, (artist_id,))

                artist_row = cursor.fetchone()

                if not artist_row:
                    return {
                        'success': False,
                        'error': f'Artist with ID {artist_id} not found'
                    }

                # Parse genres
                genres_str = artist_row['genres'] or ''
                genres = []
                if genres_str:
                    # Try to parse as JSON first (new format)
                    try:
                        import json
                        parsed_genres = json.loads(genres_str)
                        if isinstance(parsed_genres, list):
                            genres = parsed_genres
                        else:
                            genres = [str(parsed_genres)]
                    except (json.JSONDecodeError, ValueError):
                        # Fall back to comma-separated format (old format)
                        genre_set = set()
                        for genre in genres_str.split(','):
                            if genre and genre.strip():
                                genre_set.add(genre.strip())
                        genres = list(genre_set)

                # Get artist's albums with track counts and completion
                # Include albums from ALL artists with the same name (fixes duplicate artist issue)
                # Group by artist_id+title+year to merge Navidrome split albums (same artist,
                # same album split into multiple DB entries) WITHOUT merging across different artists
                cursor.execute("""
                    SELECT
                        MIN(a.id) as id,
                        a.title,
                        a.year,
                        SUM(a.track_count) as track_count,
                        MAX(a.thumb_url) as thumb_url,
                        MAX(a.musicbrainz_release_id) as musicbrainz_release_id,
                        (SELECT COUNT(*) FROM tracks t WHERE t.album_id IN (
                            SELECT a2.id FROM albums a2
                            WHERE a2.artist_id = a.artist_id
                            AND a2.title = a.title
                            AND COALESCE(a2.year, '') = COALESCE(a.year, '')
                        )) as owned_tracks
                    FROM albums a
                    WHERE a.artist_id IN (
                        SELECT id FROM artists
                        WHERE name = (SELECT name FROM artists WHERE id = ?)
                        AND server_source = (SELECT server_source FROM artists WHERE id = ?)
                    )
                    GROUP BY a.artist_id, a.title, a.year
                    ORDER BY a.year DESC, a.title
                """, (artist_id, artist_id))

                album_rows = cursor.fetchall()

                # Process albums and categorize by type
                albums = []
                eps = []
                singles = []

                # Get total stats for the artist (including all artists with same name)
                cursor.execute("""
                    SELECT
                        COUNT(*) as album_count,
                        (SELECT COUNT(*) FROM tracks WHERE album_id IN (
                            SELECT id FROM albums WHERE artist_id IN (
                                SELECT id FROM artists
                                WHERE name = (SELECT name FROM artists WHERE id = ?)
                                AND server_source = (SELECT server_source FROM artists WHERE id = ?)
                            )
                        )) as track_count
                    FROM albums
                    WHERE artist_id IN (
                        SELECT id FROM artists
                        WHERE name = (SELECT name FROM artists WHERE id = ?)
                        AND server_source = (SELECT server_source FROM artists WHERE id = ?)
                    )
                """, (artist_id, artist_id, artist_id, artist_id))

                stats_row = cursor.fetchone()
                album_count = stats_row['album_count'] if stats_row else 0
                track_count = stats_row['track_count'] if stats_row else 0

                for album_row in album_rows:
                    # Calculate completion percentage
                    expected_tracks = album_row['track_count'] or 1
                    owned_tracks = album_row['owned_tracks'] or 0
                    completion_percentage = min(100, round((owned_tracks / expected_tracks) * 100))

                    album_data = {
                        'id': album_row['id'],
                        'title': album_row['title'],
                        'year': album_row['year'],
                        'image_url': album_row['thumb_url'],
                        'owned': True,  # All albums in our DB are owned
                        'track_count': album_row['track_count'],
                        'owned_tracks': owned_tracks,
                        'musicbrainz_release_id': album_row['musicbrainz_release_id'],
                        'track_completion': completion_percentage
                    }

                    # Categorize based on actual track count and title patterns
                    # Use actual owned tracks, fallback to expected track count, then to 0
                    actual_track_count = owned_tracks or album_row['track_count'] or 0
                    title_lower = album_row['title'].lower()

                    # Check for single indicators in title
                    single_indicators = ['single', ' - single', '(single)']
                    is_single_by_title = any(indicator in title_lower for indicator in single_indicators)

                    # Check for EP indicators in title
                    ep_indicators = ['ep', ' - ep', '(ep)', 'extended play']
                    is_ep_by_title = any(indicator in title_lower for indicator in ep_indicators)

                    # Categorization logic - be more conservative about singles
                    # Only treat as single if explicitly labeled as single AND has few tracks
                    if is_single_by_title and actual_track_count <= 3:
                        singles.append(album_data)
                    elif is_ep_by_title or (4 <= actual_track_count <= 7):
                        eps.append(album_data)
                    else:
                        # Default to album for most releases, especially if track count is unknown
                        albums.append(album_data)

                # Fix image URLs if needed
                artist_image_url = artist_row['thumb_url']
                if artist_image_url and artist_image_url.startswith('/library/'):
                    # This will be fixed in the API layer
                    pass

                return {
                    'success': True,
                    'artist': {
                        'id': artist_row['id'],
                        'name': artist_row['name'],
                        'image_url': artist_image_url,
                        'genres': genres,
                        'server_source': artist_row['server_source'],
                        'musicbrainz_id': artist_row['musicbrainz_id'],
                        'deezer_id': artist_row['deezer_id'],
                        'audiodb_id': artist_row['audiodb_id'],
                        'discogs_id': artist_row['discogs_id'],
                        'spotify_artist_id': artist_row['spotify_artist_id'],
                        'itunes_artist_id': artist_row['itunes_artist_id'],
                        'lastfm_url': artist_row['lastfm_url'],
                        'genius_url': artist_row['genius_url'],
                        'tidal_id': artist_row['tidal_id'],
                        'qobuz_id': artist_row['qobuz_id'],
                        'soul_id': artist_row['soul_id'],
                        'amazon_id': artist_row['amazon_id'],
                        'lastfm_listeners': artist_row['lastfm_listeners'],
                        'lastfm_playcount': artist_row['lastfm_playcount'],
                        'lastfm_tags': artist_row['lastfm_tags'],
                        'lastfm_bio': artist_row['lastfm_bio'],
                        'album_count': album_count,
                        'track_count': track_count
                    },
                    'owned_releases': {
                        'albums': albums,
                        'eps': eps,
                        'singles': singles
                    }
                }

        except Exception as e:
            logger.error(f"Error getting artist discography for ID {artist_id}: {e}")
            return {
                'success': False,
                'error': str(e)
            }

    # ==================== Enhanced Library Management Methods ====================

    # Field whitelists for safe updates
    ARTIST_EDITABLE_FIELDS = {'name', 'genres', 'summary', 'style', 'mood', 'label'}
    ALBUM_EDITABLE_FIELDS = {'title', 'year', 'genres', 'style', 'mood', 'label', 'explicit', 'record_type', 'track_count'}
    TRACK_EDITABLE_FIELDS = {'title', 'track_number', 'bpm', 'explicit', 'style', 'mood'}

    def get_artist_full_detail(self, artist_id) -> Dict[str, Any]:
        """
        Get complete artist information with ALL columns, all albums with ALL columns,
        and all tracks per album with ALL columns. For the enhanced library management view.
        """
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Get artist with all columns
                cursor.execute("SELECT * FROM artists WHERE id = ?", (artist_id,))
                artist_row = cursor.fetchone()
                if not artist_row:
                    return {'success': False, 'error': f'Artist with ID {artist_id} not found'}

                artist_name = artist_row['name']
                server_source = artist_row['server_source']

                # Parse artist data
                artist_data = dict(artist_row)
                # Parse genres JSON
                if artist_data.get('genres'):
                    try:
                        parsed = json.loads(artist_data['genres'])
                        artist_data['genres'] = parsed if isinstance(parsed, list) else [str(parsed)]
                    except (json.JSONDecodeError, ValueError):
                        artist_data['genres'] = [g.strip() for g in artist_data['genres'].split(',') if g.strip()]
                else:
                    artist_data['genres'] = []

                # Get all album IDs for this artist (including same-name artists on same server)
                cursor.execute("""
                    SELECT id FROM artists
                    WHERE name = ? AND server_source = ?
                """, (artist_name, server_source))
                artist_ids = [row['id'] for row in cursor.fetchall()]

                # Get all albums with all columns
                placeholders = ','.join('?' * len(artist_ids))
                cursor.execute(f"""
                    SELECT * FROM albums
                    WHERE artist_id IN ({placeholders})
                    ORDER BY year DESC, title
                """, artist_ids)
                album_rows = cursor.fetchall()

                albums = []
                for album_row in album_rows:
                    album_data = dict(album_row)
                    # Parse album genres
                    if album_data.get('genres'):
                        try:
                            parsed = json.loads(album_data['genres'])
                            album_data['genres'] = parsed if isinstance(parsed, list) else [str(parsed)]
                        except (json.JSONDecodeError, ValueError):
                            album_data['genres'] = [g.strip() for g in album_data['genres'].split(',') if g.strip()]
                    else:
                        album_data['genres'] = []

                    # Get all tracks for this album with all columns
                    cursor.execute("""
                        SELECT * FROM tracks
                        WHERE album_id = ?
                        ORDER BY track_number, title
                    """, (album_data['id'],))
                    track_rows = cursor.fetchall()
                    album_data['tracks'] = [dict(tr) for tr in track_rows]

                    # Determine record type from data if not set
                    if not album_data.get('record_type'):
                        track_count = len(album_data['tracks']) or album_data.get('track_count') or 0
                        title_lower = (album_data.get('title') or '').lower()
                        if any(ind in title_lower for ind in ['single', ' - single', '(single)']) and track_count <= 3:
                            album_data['record_type'] = 'single'
                        elif any(ind in title_lower for ind in ['ep', ' - ep', '(ep)', 'extended play']) or (4 <= track_count <= 7):
                            album_data['record_type'] = 'ep'
                        else:
                            album_data['record_type'] = 'album'

                    albums.append(album_data)

                return {
                    'success': True,
                    'artist': artist_data,
                    'albums': albums
                }

        except Exception as e:
            logger.error(f"Error getting artist full detail for ID {artist_id}: {e}")
            return {'success': False, 'error': str(e)}

    def update_artist_fields(self, artist_id, updates: Dict[str, Any]) -> Dict[str, Any]:
        """Update artist metadata fields. Only whitelisted fields are accepted."""
        valid_updates = {k: v for k, v in updates.items() if k in self.ARTIST_EDITABLE_FIELDS}
        if not valid_updates:
            return {'success': False, 'error': 'No valid fields to update'}

        # Serialize genres to JSON if present
        if 'genres' in valid_updates:
            if isinstance(valid_updates['genres'], list):
                valid_updates['genres'] = json.dumps(valid_updates['genres'])

        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                set_clause = ', '.join(f'{k} = ?' for k in valid_updates)
                values = list(valid_updates.values()) + [artist_id]
                cursor.execute(f"UPDATE artists SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", values)
                conn.commit()
                if cursor.rowcount == 0:
                    return {'success': False, 'error': f'Artist {artist_id} not found'}
                return {'success': True, 'updated_fields': list(valid_updates.keys())}
        except Exception as e:
            logger.error(f"Error updating artist {artist_id}: {e}")
            return {'success': False, 'error': str(e)}

    def update_album_fields(self, album_id, updates: Dict[str, Any]) -> Dict[str, Any]:
        """Update album metadata fields. Only whitelisted fields are accepted."""
        valid_updates = {k: v for k, v in updates.items() if k in self.ALBUM_EDITABLE_FIELDS}
        if not valid_updates:
            return {'success': False, 'error': 'No valid fields to update'}

        if 'genres' in valid_updates:
            if isinstance(valid_updates['genres'], list):
                valid_updates['genres'] = json.dumps(valid_updates['genres'])

        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                set_clause = ', '.join(f'{k} = ?' for k in valid_updates)
                values = list(valid_updates.values()) + [album_id]
                cursor.execute(f"UPDATE albums SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", values)
                conn.commit()
                if cursor.rowcount == 0:
                    return {'success': False, 'error': f'Album {album_id} not found'}
                return {'success': True, 'updated_fields': list(valid_updates.keys())}
        except Exception as e:
            logger.error(f"Error updating album {album_id}: {e}")
            return {'success': False, 'error': str(e)}

    def update_track_fields(self, track_id, updates: Dict[str, Any]) -> Dict[str, Any]:
        """Update track metadata fields. Only whitelisted fields are accepted."""
        valid_updates = {k: v for k, v in updates.items() if k in self.TRACK_EDITABLE_FIELDS}
        if not valid_updates:
            return {'success': False, 'error': 'No valid fields to update'}

        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                set_clause = ', '.join(f'{k} = ?' for k in valid_updates)
                values = list(valid_updates.values()) + [track_id]
                cursor.execute(f"UPDATE tracks SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", values)
                conn.commit()
                if cursor.rowcount == 0:
                    return {'success': False, 'error': f'Track {track_id} not found'}
                return {'success': True, 'updated_fields': list(valid_updates.keys())}
        except Exception as e:
            logger.error(f"Error updating track {track_id}: {e}")
            return {'success': False, 'error': str(e)}

    def batch_update_tracks(self, track_ids: List[str], updates: Dict[str, Any]) -> Dict[str, Any]:
        """Batch update multiple tracks with the same field values."""
        valid_updates = {k: v for k, v in updates.items() if k in self.TRACK_EDITABLE_FIELDS}
        if not valid_updates:
            return {'success': False, 'error': 'No valid fields to update'}
        if not track_ids:
            return {'success': False, 'error': 'No track IDs provided'}

        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                set_clause = ', '.join(f'{k} = ?' for k in valid_updates)
                placeholders = ','.join('?' * len(track_ids))
                values = list(valid_updates.values()) + list(track_ids)
                cursor.execute(
                    f"UPDATE tracks SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})",
                    values
                )
                conn.commit()
                return {'success': True, 'updated_count': cursor.rowcount, 'updated_fields': list(valid_updates.keys())}
        except Exception as e:
            logger.error(f"Error batch updating tracks: {e}")
            return {'success': False, 'error': str(e)}

    # ==================== Discovery Match Cache Methods ====================

    def get_discovery_cache_match(self, normalized_title: str, normalized_artist: str, provider: str) -> Optional[Dict]:
        """Look up a cached discovery match. Returns the matched_data dict or None.
        Also bumps last_used_at and use_count on hit."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT matched_data_json, match_confidence FROM discovery_match_cache
                WHERE normalized_title = ? AND normalized_artist = ? AND provider = ?
            """, (normalized_title, normalized_artist, provider))
            row = cursor.fetchone()
            if row:
                # Bump usage stats
                cursor.execute("""
                    UPDATE discovery_match_cache
                    SET last_used_at = CURRENT_TIMESTAMP, use_count = use_count + 1
                    WHERE normalized_title = ? AND normalized_artist = ? AND provider = ?
                """, (normalized_title, normalized_artist, provider))
                conn.commit()
                return json.loads(row['matched_data_json'])
            return None
        except Exception as e:
            logger.error(f"Error reading discovery cache: {e}")
            return None

    def save_discovery_cache_match(self, normalized_title: str, normalized_artist: str,
                                    provider: str, confidence: float, matched_data: Dict,
                                    original_title: str = None, original_artist: str = None) -> bool:
        """Save a discovery match to cache. Uses INSERT OR REPLACE for upsert."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                INSERT OR REPLACE INTO discovery_match_cache
                (normalized_title, normalized_artist, provider, match_confidence,
                 matched_data_json, original_title, original_artist,
                 created_at, last_used_at, use_count)
                VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
            """, (normalized_title, normalized_artist, provider, confidence,
                  json.dumps(matched_data), original_title, original_artist))
            conn.commit()
            return True
        except Exception as e:
            logger.error(f"Error saving discovery cache: {e}")
            return False

    # ==================== Sync Match Cache ====================

    def read_sync_match_cache(self, spotify_track_id: str, server_source: str) -> Optional[Dict]:
        """Read a cached sync match. Returns {server_track_id, server_track_title, confidence} or None.
        Also bumps last_used_at and use_count on hit."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT server_track_id, server_track_title, confidence FROM sync_match_cache
                WHERE spotify_track_id = ? AND server_source = ?
            """, (spotify_track_id, server_source))
            row = cursor.fetchone()
            if row:
                cursor.execute("""
                    UPDATE sync_match_cache
                    SET last_used_at = CURRENT_TIMESTAMP, use_count = use_count + 1
                    WHERE spotify_track_id = ? AND server_source = ?
                """, (spotify_track_id, server_source))
                conn.commit()
                return {
                    'server_track_id': row['server_track_id'],
                    'server_track_title': row['server_track_title'],
                    'confidence': row['confidence'],
                }
            return None
        except Exception as e:
            logger.error(f"Error reading sync match cache: {e}")
            return None

    def save_sync_match_cache(self, spotify_track_id: str, normalized_title: str,
                               normalized_artist: str, server_source: str,
                               server_track_id, server_track_title: str,
                               confidence: float) -> bool:
        """Save a sync match to cache. Uses INSERT OR REPLACE for upsert."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                INSERT OR REPLACE INTO sync_match_cache
                (spotify_track_id, normalized_title, normalized_artist, server_source,
                 server_track_id, server_track_title, confidence,
                 created_at, last_used_at, use_count)
                VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
            """, (spotify_track_id, normalized_title, normalized_artist, server_source,
                  server_track_id, server_track_title, confidence))
            conn.commit()
            return True
        except Exception as e:
            logger.error(f"Error saving sync match cache: {e}")
            return False

    def invalidate_sync_match_cache(self, server_source: str = None) -> int:
        """Clear sync match cache entries. If server_source given, only clear that server's entries."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            if server_source:
                cursor.execute("DELETE FROM sync_match_cache WHERE server_source = ?", (server_source,))
            else:
                cursor.execute("DELETE FROM sync_match_cache")
            conn.commit()
            return cursor.rowcount
        except Exception as e:
            logger.error(f"Error invalidating sync match cache: {e}")
            return 0

    # ==================== Download Blacklist Methods ====================

    def add_to_blacklist(self, track_title: str, track_artist: str, blocked_filename: str, blocked_username: str, reason: str = 'user_rejected') -> bool:
        """Add a download source to the blacklist so it won't be used again."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                INSERT OR IGNORE INTO download_blacklist
                (track_title, track_artist, blocked_filename, blocked_username, reason)
                VALUES (?, ?, ?, ?, ?)
            """, (track_title, track_artist, blocked_filename, blocked_username, reason))
            conn.commit()
            return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error adding to blacklist: {e}")
            return False

    def is_blacklisted(self, username: str, filename: str) -> bool:
        """Check if a download source is blacklisted."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT 1 FROM download_blacklist
                WHERE blocked_username = ? AND blocked_filename = ?
                LIMIT 1
            """, (username, filename))
            return cursor.fetchone() is not None
        except Exception:
            return False

    def get_blacklist(self, limit: int = 100, offset: int = 0) -> list:
        """Get blacklist entries."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT id, track_title, track_artist, blocked_filename, blocked_username, reason, created_at
                FROM download_blacklist
                ORDER BY created_at DESC
                LIMIT ? OFFSET ?
            """, (limit, offset))
            return [dict(row) for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting blacklist: {e}")
            return []

    def remove_from_blacklist(self, blacklist_id: int) -> bool:
        """Remove an entry from the blacklist."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("DELETE FROM download_blacklist WHERE id = ?", (blacklist_id,))
            conn.commit()
            return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error removing from blacklist: {e}")
            return False

    # ==================== Discovery Artist Blacklist Methods ====================

    def add_to_discovery_blacklist(self, artist_name: str, spotify_id: str = None,
                                   itunes_id: str = None, deezer_id: str = None) -> bool:
        """Block an artist from appearing in discovery results."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                INSERT OR REPLACE INTO discovery_artist_blacklist
                (artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id)
                VALUES (?, ?, ?, ?)
            """, (artist_name.strip(), spotify_id, itunes_id, deezer_id))
            conn.commit()
            return True
        except Exception as e:
            logger.error(f"Error adding to discovery blacklist: {e}")
            return False

    def remove_from_discovery_blacklist(self, blacklist_id: int) -> bool:
        """Remove an artist from the discovery blacklist."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("DELETE FROM discovery_artist_blacklist WHERE id = ?", (blacklist_id,))
            conn.commit()
            return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error removing from discovery blacklist: {e}")
            return False

    def get_discovery_blacklist(self) -> list:
        """Get all blacklisted discovery artists."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT id, artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id, created_at
                FROM discovery_artist_blacklist ORDER BY created_at DESC
            """)
            return [dict(r) for r in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting discovery blacklist: {e}")
            return []

    def get_discovery_blacklist_names(self) -> set:
        """Get set of blacklisted artist names (lowercased) for fast filtering."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("SELECT LOWER(artist_name) FROM discovery_artist_blacklist")
            return {r[0] for r in cursor.fetchall()}
        except Exception as e:
            logger.error(f"Error getting discovery blacklist names: {e}")
            return set()

    # ==================== Liked Artists Pool Methods ====================

    @staticmethod
    def _normalize_artist_name_for_dedup(name: str) -> str:
        """Normalize artist name for deduplication. Lowercases, strips diacritics,
        removes 'the ' prefix, collapses whitespace."""
        import unicodedata
        if not name:
            return ''
        n = unicodedata.normalize('NFKD', name)
        n = ''.join(c for c in n if not unicodedata.combining(c))
        n = n.lower().strip()
        if n.startswith('the '):
            n = n[4:]
        # Handle "Artist, The" format (Last.fm)
        if n.endswith(', the'):
            n = n[:-5]
        n = ' '.join(n.split())  # collapse whitespace
        return n

    # Known placeholder/default images that should be treated as "no image"
    _PLACEHOLDER_IMAGES = {
        '2a96cbd8b46e442fc41c2b86b821562f',  # Last.fm default star
    }

    @classmethod
    def _is_placeholder_image(cls, url: str) -> bool:
        """Check if an image URL is a known service placeholder."""
        if not url:
            return True
        return any(ph in url for ph in cls._PLACEHOLDER_IMAGES)

    def upsert_liked_artist(self, artist_name: str, source_service: str,
                            source_id: str = None, source_id_type: str = None,
                            image_url: str = None, genres: list = None,
                            profile_id: int = 1) -> bool:
        """Insert or merge a liked artist into the pool. Deduplicates by normalized name."""
        try:
            import json
            # Reject known placeholder images
            if self._is_placeholder_image(image_url):
                image_url = None
            normalized = self._normalize_artist_name_for_dedup(artist_name)
            if not normalized:
                return False

            conn = self._get_connection()
            cursor = conn.cursor()

            # Check if exists to merge source_services
            cursor.execute(
                "SELECT id, source_services FROM liked_artists_pool WHERE profile_id = ? AND normalized_name = ?",
                (profile_id, normalized)
            )
            existing = cursor.fetchone()

            if existing:
                # Merge source into existing entry
                current_sources = json.loads(existing['source_services'] or '[]')
                if source_service not in current_sources:
                    current_sources.append(source_service)

                # Build SET clause with COALESCE for IDs and image
                set_parts = [
                    "source_services = ?",
                    "updated_at = CURRENT_TIMESTAMP",
                    "last_fetched_at = CURRENT_TIMESTAMP",
                ]
                params = [json.dumps(current_sources)]

                if source_id and source_id_type:
                    col = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id',
                           'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id'}.get(source_id_type)
                    if col:
                        set_parts.append(f"{col} = COALESCE({col}, ?)")
                        params.append(source_id)
                if image_url:
                    set_parts.append("image_url = COALESCE(image_url, ?)")
                    params.append(image_url)
                if genres:
                    set_parts.append("genres = COALESCE(genres, ?)")
                    params.append(json.dumps(genres))

                params.extend([profile_id, normalized])
                cursor.execute(
                    f"UPDATE liked_artists_pool SET {', '.join(set_parts)} WHERE profile_id = ? AND normalized_name = ?",
                    params
                )
            else:
                # New entry
                sources_json = json.dumps([source_service])
                id_cols = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id',
                           'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id'}
                col_values = {v: None for v in id_cols.values()}
                if source_id and source_id_type and source_id_type in id_cols:
                    col_values[id_cols[source_id_type]] = source_id

                cursor.execute("""
                    INSERT INTO liked_artists_pool
                    (artist_name, normalized_name, spotify_artist_id, itunes_artist_id,
                     deezer_artist_id, discogs_artist_id, image_url, genres,
                     source_services, profile_id, last_fetched_at)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
                """, (
                    artist_name, normalized, col_values['spotify_artist_id'],
                    col_values['itunes_artist_id'], col_values['deezer_artist_id'],
                    col_values['discogs_artist_id'], image_url,
                    json.dumps(genres) if genres else None, sources_json, profile_id
                ))

            conn.commit()
            return True
        except Exception as e:
            logger.error(f"Error upserting liked artist '{artist_name}': {e}")
            return False

    def get_liked_artists(self, profile_id: int = 1, limit: int = None,
                          random: bool = False, matched_only: bool = True,
                          page: int = 1, per_page: int = 50,
                          search: str = None, source_filter: str = None,
                          sort: str = 'name',
                          require_source_id: str = None,
                          require_image: bool = False) -> dict:
        """Get liked artists from the pool. Returns {artists: [...], total: N}.
        require_source_id: column name like 'spotify_artist_id' — only return artists with this ID set.
        require_image: if True, only return artists with a non-empty image_url."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()

            where = ["profile_id = ?"]
            params = [profile_id]
            if matched_only:
                where.append("match_status = 'matched'")
            if require_source_id:
                where.append(f"{require_source_id} IS NOT NULL AND {require_source_id} != ''")
            if require_image:
                where.append("image_url IS NOT NULL AND image_url != ''")
            if search:
                where.append("artist_name LIKE ? COLLATE NOCASE")
                params.append(f"%{search}%")
            if source_filter:
                where.append("source_services LIKE ?")
                params.append(f'%"{source_filter}"%')

            where_clause = " AND ".join(where)

            cursor.execute(f"SELECT COUNT(*) FROM liked_artists_pool WHERE {where_clause}", params)
            total = cursor.fetchone()[0]

            order = "RANDOM()" if random else {
                'name': 'artist_name COLLATE NOCASE',
                'recent': 'created_at DESC',
                'source': 'source_services, artist_name COLLATE NOCASE'
            }.get(sort, 'artist_name COLLATE NOCASE')

            query_limit = limit if limit else per_page
            offset = (page - 1) * per_page if not limit else 0

            cursor.execute(f"""
                SELECT * FROM liked_artists_pool
                WHERE {where_clause}
                ORDER BY {order}
                LIMIT ? OFFSET ?
            """, params + [query_limit, offset])

            import json
            artists = []
            for r in cursor.fetchall():
                d = dict(r)
                d['source_services'] = json.loads(d['source_services'] or '[]')
                d['genres'] = json.loads(d['genres']) if d['genres'] else []
                artists.append(d)

            return {'artists': artists, 'total': total}
        except Exception as e:
            logger.error(f"Error getting liked artists: {e}")
            return {'artists': [], 'total': 0}

    def get_liked_artists_last_fetch(self, profile_id: int = 1):
        """Get the most recent fetch timestamp."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute(
                "SELECT MAX(last_fetched_at) FROM liked_artists_pool WHERE profile_id = ?",
                (profile_id,)
            )
            row = cursor.fetchone()
            return row[0] if row and row[0] else None
        except Exception:
            return None

    def update_liked_artist_match(self, pool_id: int, active_source: str = None,
                                  active_source_id: str = None, image_url: str = None,
                                  all_ids: dict = None) -> bool:
        """Mark a liked artist as matched. Stores all discovered source IDs, not just active.
        all_ids: optional dict like {'spotify_artist_id': '...', 'itunes_artist_id': '...'}"""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            set_parts = ["match_status = 'matched'", "updated_at = CURRENT_TIMESTAMP"]
            params = []

            if active_source and active_source_id:
                set_parts.append("active_source = ?")
                set_parts.append("active_source_id = ?")
                params.extend([active_source, active_source_id])

            # Store all discovered source IDs (COALESCE preserves existing values)
            if all_ids:
                for col in ('spotify_artist_id', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'musicbrainz_artist_id'):
                    val = all_ids.get(col)
                    if val:
                        set_parts.append(f"{col} = COALESCE({col}, ?)")
                        params.append(str(val))

            # Update image — replace if current is NULL or empty string
            if image_url:
                set_parts.append("image_url = CASE WHEN image_url IS NULL OR image_url = '' THEN ? ELSE image_url END")
                params.append(image_url)

            params.append(pool_id)
            cursor.execute(f"UPDATE liked_artists_pool SET {', '.join(set_parts)} WHERE id = ?", params)
            conn.commit()
            return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error updating liked artist match: {e}")
            return False

    def sync_liked_artists_watchlist_flags(self, profile_id: int = 1) -> int:
        """Batch-update on_watchlist flags by checking against watchlist_artists.
        Uses case-insensitive artist_name comparison (not normalized_name) to avoid
        normalization mismatches like 'The Beatles' vs 'beatles'."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            # Reset all, then set matches
            cursor.execute(
                "UPDATE liked_artists_pool SET on_watchlist = 0 WHERE profile_id = ?",
                (profile_id,)
            )
            cursor.execute("""
                UPDATE liked_artists_pool SET on_watchlist = 1
                WHERE profile_id = ? AND EXISTS (
                    SELECT 1 FROM watchlist_artists wa
                    WHERE wa.profile_id = liked_artists_pool.profile_id
                      AND wa.artist_name = liked_artists_pool.artist_name COLLATE NOCASE
                )
            """, (profile_id,))
            conn.commit()
            return cursor.rowcount
        except Exception as e:
            logger.error(f"Error syncing liked artists watchlist flags: {e}")
            return 0

    def get_liked_artists_pending_match(self, profile_id: int = 1, limit: int = 50) -> list:
        """Get artists that haven't been matched to the active source yet."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT * FROM liked_artists_pool
                WHERE profile_id = ? AND match_status = 'pending'
                ORDER BY created_at
                LIMIT ?
            """, (profile_id, limit))
            import json
            return [dict(r) for r in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting pending liked artists: {e}")
            return []

    def clear_liked_artists(self, profile_id: int = 1) -> int:
        """Clear all liked artists for a profile."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("DELETE FROM liked_artists_pool WHERE profile_id = ?", (profile_id,))
            conn.commit()
            return cursor.rowcount
        except Exception as e:
            logger.error(f"Error clearing liked artists: {e}")
            return 0

    # ==================== Liked Albums Pool Methods ====================

    @staticmethod
    def _normalize_album_key(artist_name: str, album_name: str) -> str:
        """Normalize artist+album into a dedup key."""
        import unicodedata
        def _norm(s):
            if not s:
                return ''
            n = unicodedata.normalize('NFKD', s)
            n = ''.join(c for c in n if not unicodedata.combining(c))
            n = n.lower().strip()
            if n.startswith('the '):
                n = n[4:]
            return ' '.join(n.split())
        return f"{_norm(artist_name)}::{_norm(album_name)}"

    def upsert_liked_album(self, album_name: str, artist_name: str, source_service: str,
                           source_id: str = None, source_id_type: str = None,
                           image_url: str = None, release_date: str = None,
                           total_tracks: int = 0, profile_id: int = 1) -> bool:
        """Insert or merge a liked album into the pool. Deduplicates by normalized artist+album key."""
        try:
            import json
            if self._is_placeholder_image(image_url):
                image_url = None
            normalized = self._normalize_album_key(artist_name, album_name)
            if not normalized or '::' not in normalized:
                return False

            conn = self._get_connection()
            cursor = conn.cursor()

            cursor.execute(
                "SELECT id, source_services FROM liked_albums_pool WHERE profile_id = ? AND normalized_key = ?",
                (profile_id, normalized)
            )
            existing = cursor.fetchone()

            if existing:
                current_sources = json.loads(existing['source_services'] or '[]')
                if source_service not in current_sources:
                    current_sources.append(source_service)

                set_parts = [
                    "source_services = ?",
                    "updated_at = CURRENT_TIMESTAMP",
                    "last_fetched_at = CURRENT_TIMESTAMP",
                ]
                params = [json.dumps(current_sources)]

                if source_id and source_id_type:
                    col = {'spotify': 'spotify_album_id', 'tidal': 'tidal_album_id',
                           'deezer': 'deezer_album_id',
                           'discogs': 'discogs_release_id'}.get(source_id_type)
                    if col:
                        set_parts.append(f"{col} = COALESCE({col}, ?)")
                        params.append(source_id)
                if image_url:
                    set_parts.append("image_url = COALESCE(image_url, ?)")
                    params.append(image_url)
                if release_date:
                    set_parts.append("release_date = COALESCE(release_date, ?)")
                    params.append(release_date)
                if total_tracks:
                    set_parts.append("total_tracks = COALESCE(NULLIF(total_tracks, 0), ?)")
                    params.append(total_tracks)

                params.extend([profile_id, normalized])
                cursor.execute(
                    f"UPDATE liked_albums_pool SET {', '.join(set_parts)} WHERE profile_id = ? AND normalized_key = ?",
                    params
                )
            else:
                sources_json = json.dumps([source_service])
                id_cols = {'spotify': 'spotify_album_id', 'tidal': 'tidal_album_id',
                           'deezer': 'deezer_album_id',
                           'discogs': 'discogs_release_id'}
                col_values = {v: None for v in id_cols.values()}
                if source_id and source_id_type and source_id_type in id_cols:
                    col_values[id_cols[source_id_type]] = source_id

                cursor.execute("""
                    INSERT INTO liked_albums_pool
                    (album_name, artist_name, normalized_key, spotify_album_id, tidal_album_id,
                     deezer_album_id, discogs_release_id, image_url, release_date, total_tracks,
                     source_services, profile_id, last_fetched_at)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
                """, (
                    album_name, artist_name, normalized,
                    col_values['spotify_album_id'], col_values['tidal_album_id'],
                    col_values['deezer_album_id'], col_values['discogs_release_id'],
                    image_url, release_date, total_tracks or 0,
                    sources_json, profile_id
                ))

            conn.commit()
            return True
        except Exception as e:
            logger.error(f"Error upserting liked album '{album_name}' by '{artist_name}': {e}")
            return False

    def get_liked_albums(self, profile_id: int = 1, page: int = 1, per_page: int = 50,
                         search: str = None, source_filter: str = None,
                         sort: str = 'artist_name') -> dict:
        """Get liked albums from the pool. Returns {albums: [...], total: N}."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()

            where = ["profile_id = ?"]
            params = [profile_id]
            if search:
                where.append("(album_name LIKE ? COLLATE NOCASE OR artist_name LIKE ? COLLATE NOCASE)")
                params.extend([f"%{search}%", f"%{search}%"])
            if source_filter:
                where.append("source_services LIKE ?")
                params.append(f'%"{source_filter}"%')

            where_clause = " AND ".join(where)

            cursor.execute(f"SELECT COUNT(*) FROM liked_albums_pool WHERE {where_clause}", params)
            total = cursor.fetchone()[0]

            order = {
                'artist_name': 'artist_name COLLATE NOCASE, album_name COLLATE NOCASE',
                'album_name': 'album_name COLLATE NOCASE',
                'recent': 'created_at DESC',
                'release_date': 'release_date DESC',
            }.get(sort, 'artist_name COLLATE NOCASE')

            offset = (page - 1) * per_page
            cursor.execute(f"""
                SELECT * FROM liked_albums_pool
                WHERE {where_clause}
                ORDER BY {order}
                LIMIT ? OFFSET ?
            """, params + [per_page, offset])

            import json
            albums = []
            for r in cursor.fetchall():
                d = dict(r)
                d['source_services'] = json.loads(d['source_services'] or '[]')
                albums.append(d)

            return {'albums': albums, 'total': total}
        except Exception as e:
            logger.error(f"Error getting liked albums: {e}")
            return {'albums': [], 'total': 0}

    def get_liked_albums_last_fetch(self, profile_id: int = 1):
        """Get the most recent fetch timestamp."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute(
                "SELECT MAX(last_fetched_at) FROM liked_albums_pool WHERE profile_id = ?",
                (profile_id,)
            )
            row = cursor.fetchone()
            return row[0] if row and row[0] else None
        except Exception:
            return None

    def clear_liked_albums(self, profile_id: int = 1) -> int:
        """Clear all liked albums for a profile."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("DELETE FROM liked_albums_pool WHERE profile_id = ?", (profile_id,))
            conn.commit()
            return cursor.rowcount
        except Exception as e:
            logger.error(f"Error clearing liked albums: {e}")
            return 0

    # ==================== Track Download Provenance Methods ====================

    def record_track_download(self, file_path: str, source_service: str, source_username: str,
                               source_filename: str, source_size: int = 0, audio_quality: str = '',
                               track_title: str = '', track_artist: str = '', track_album: str = '',
                               status: str = 'completed', track_id: str = None,
                               bit_depth: int = None, sample_rate: int = None, bitrate: int = None,
                               spotify_track_id: Optional[str] = None,
                               itunes_track_id: Optional[str] = None,
                               deezer_track_id: Optional[str] = None,
                               tidal_track_id: Optional[str] = None,
                               qobuz_track_id: Optional[str] = None,
                               musicbrainz_recording_id: Optional[str] = None,
                               audiodb_id: Optional[str] = None,
                               soul_id: Optional[str] = None,
                               isrc: Optional[str] = None) -> Optional[int]:
        """Record a download with full source provenance. Returns the record ID.

        External-ID kwargs (spotify_track_id et al.) capture the metadata-
        source identity that the user originally asked for — they're written
        at download time so the watchlist scanner can recognize the file as
        already present without waiting for the async enrichment workers
        to backfill them onto the ``tracks`` row.
        """
        try:
            conn = self._get_connection()
            cursor = conn.cursor()

            # Try to link to existing library track by file path if track_id not given
            if not track_id and file_path:
                cursor.execute("SELECT id FROM tracks WHERE file_path = ? LIMIT 1", (file_path,))
                row = cursor.fetchone()
                if not row:
                    # Fallback: match by filename suffix (handles server path vs local path differences)
                    import os as _os
                    fname = _os.path.basename(file_path.replace('\\', '/'))
                    if fname:
                        cursor.execute(
                            "SELECT id FROM tracks WHERE file_path LIKE ? OR file_path LIKE ? LIMIT 1",
                            (f'%/{fname}', f'%\\{fname}')
                        )
                        row = cursor.fetchone()
                if row:
                    track_id = str(row[0])

            cursor.execute("""
                INSERT INTO track_downloads
                (track_id, file_path, source_service, source_username, source_filename,
                 source_size, audio_quality, track_title, track_artist, track_album, status,
                 bit_depth, sample_rate, bitrate,
                 spotify_track_id, itunes_track_id, deezer_track_id, tidal_track_id,
                 qobuz_track_id, musicbrainz_recording_id, audiodb_id, soul_id, isrc)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """, (track_id, file_path, source_service, source_username, source_filename,
                  source_size, audio_quality, track_title, track_artist, track_album, status,
                  bit_depth, sample_rate, bitrate,
                  spotify_track_id, itunes_track_id, deezer_track_id, tidal_track_id,
                  qobuz_track_id, musicbrainz_recording_id, audiodb_id, soul_id, isrc))
            conn.commit()
            return cursor.lastrowid
        except Exception as e:
            logger.error(f"Error recording track download: {e}")
            return None

    def get_provenance_by_file_path(self, file_path: str) -> Optional[Dict[str, Any]]:
        """Return the most recent track_downloads row matching ``file_path``.

        Tries exact match first, then a basename-suffix LIKE fallback for
        cases where the media-server scan reports the file at a slightly
        different path than what was recorded at download time (Windows
        separators, symlink resolution, container mount-root differences).
        """
        if not file_path:
            return None
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute(
                "SELECT * FROM track_downloads WHERE file_path = ? ORDER BY id DESC LIMIT 1",
                (file_path,),
            )
            row = cursor.fetchone()
            if row is None:
                import os as _os
                fname = _os.path.basename(file_path.replace('\\', '/'))
                if fname:
                    cursor.execute(
                        "SELECT * FROM track_downloads WHERE file_path LIKE ? OR file_path LIKE ? "
                        "ORDER BY id DESC LIMIT 1",
                        (f'%/{fname}', f'%\\{fname}'),
                    )
                    row = cursor.fetchone()
            if row is None:
                return None
            try:
                return dict(row)
            except (TypeError, ValueError):
                cols = [c[0] for c in cursor.description]
                return dict(zip(cols, row, strict=False))
        except Exception as exc:
            logger.debug(f"get_provenance_by_file_path failed: {exc}")
            return None

    def backfill_track_external_ids_from_provenance(self, track_id: str, file_path: Optional[str]) -> int:
        """Copy external IDs from ``track_downloads`` onto a ``tracks`` row.

        Idempotent: only writes columns that are currently NULL/empty on
        the tracks row AND have a value in the provenance row. Returns the
        number of columns updated. Called from
        ``insert_or_update_media_track`` immediately after the row is
        inserted/updated so freshly synced media-server rows pick up
        whatever IDs SoulSync already knew at download time.
        """
        if not track_id or not file_path:
            return 0
        prov = self.get_provenance_by_file_path(file_path)
        if not prov:
            return 0

        # Map provenance column -> tracks column. Different naming
        # conventions because tracks.* uses shorter names (``deezer_id``,
        # ``tidal_id``, ``qobuz_id``) while track_downloads uses the
        # explicit ``_track_id`` suffix to avoid ambiguity.
        prov_to_tracks = {
            'spotify_track_id': 'spotify_track_id',
            'itunes_track_id': 'itunes_track_id',
            'deezer_track_id': 'deezer_id',
            'tidal_track_id': 'tidal_id',
            'qobuz_track_id': 'qobuz_id',
            'musicbrainz_recording_id': 'musicbrainz_recording_id',
            'audiodb_id': 'audiodb_id',
            'soul_id': 'soul_id',
            'isrc': 'isrc',
        }

        updates: Dict[str, str] = {}
        for prov_col, track_col in prov_to_tracks.items():
            val = prov.get(prov_col)
            if not val:
                continue
            updates[track_col] = str(val)
        if not updates:
            return 0

        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            # Coalesce-update: only fill empty columns. Preserves any IDs
            # the enrichment worker already populated (those are usually
            # more reliable than provenance for non-primary sources).
            set_clauses = []
            params = []
            for track_col, val in updates.items():
                set_clauses.append(f"{track_col} = COALESCE(NULLIF({track_col}, ''), ?)")
                params.append(val)
            params.append(track_id)
            cursor.execute(
                f"UPDATE tracks SET {', '.join(set_clauses)} WHERE id = ?",
                params,
            )
            conn.commit()
            return cursor.rowcount or 0
        except Exception as exc:
            logger.debug(f"backfill_track_external_ids_from_provenance failed: {exc}")
            return 0

    def get_track_downloads(self, track_id: str) -> list:
        """Get all download records for a library track."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT * FROM track_downloads
                WHERE track_id = ?
                ORDER BY created_at DESC
            """, (str(track_id),))
            return [dict(row) for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting track downloads: {e}")
            return []

    def update_provenance_file_path(self, old_path: str, new_path: str) -> bool:
        """Update file_path in provenance records when a file is transcoded/moved."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                UPDATE track_downloads SET file_path = ? WHERE file_path = ?
            """, (new_path, old_path))
            conn.commit()
            return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error updating provenance file path: {e}")
            return False

    def get_download_by_file_path(self, file_path: str) -> Optional[dict]:
        """Find the most recent download record for a file path."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT * FROM track_downloads
                WHERE file_path = ?
                ORDER BY created_at DESC
                LIMIT 1
            """, (file_path,))
            row = cursor.fetchone()
            return dict(row) if row else None
        except Exception as e:
            logger.error(f"Error getting download by file path: {e}")
            return None

    def get_download_by_filename(self, filename: str, link_track_id: str = None) -> Optional[dict]:
        """Find a download record by filename suffix (handles server vs local path mismatches).
        Optionally back-links the track_id on the found record for future fast lookups."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            # Match using both separator styles to handle Windows vs Unix paths
            cursor.execute("""
                SELECT * FROM track_downloads
                WHERE file_path LIKE ? OR file_path LIKE ?
                ORDER BY created_at DESC
                LIMIT 1
            """, (f'%/{filename}', f'%\\{filename}'))
            row = cursor.fetchone()
            if row and link_track_id:
                # Back-link this record so future track_id lookups work directly
                cursor.execute(
                    "UPDATE track_downloads SET track_id = ? WHERE id = ? AND track_id IS NULL",
                    (str(link_track_id), row['id'])
                )
                conn.commit()
            conn.close()
            return dict(row) if row else None
        except Exception as e:
            logger.error(f"Error getting download by filename: {e}")
            return None

    # ==================== Discovery Pool Methods ====================

    def get_discovery_pool_matched(self, limit: int = 500) -> list:
        """Get all cached discovery matches, ordered by most recently used."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT id, original_title, original_artist, normalized_title, normalized_artist,
                       provider, match_confidence, matched_data_json, use_count, last_used_at, created_at
                FROM discovery_match_cache
                ORDER BY last_used_at DESC
                LIMIT ?
            """, (limit,))
            results = []
            for row in cursor.fetchall():
                try:
                    matched_data = json.loads(row['matched_data_json'])
                except (json.JSONDecodeError, TypeError):
                    matched_data = {}
                results.append({
                    'id': row['id'],
                    'original_title': row['original_title'] or row['normalized_title'],
                    'original_artist': row['original_artist'] or row['normalized_artist'],
                    'provider': row['provider'],
                    'confidence': row['match_confidence'],
                    'matched_data': matched_data,
                    'use_count': row['use_count'],
                    'last_used_at': row['last_used_at'],
                    'created_at': row['created_at'],
                })
            return results
        except Exception as e:
            logger.error(f"Error getting discovery pool matched: {e}")
            return []

    def get_discovery_pool_failed(self, profile_id: int = None, playlist_id: int = None) -> list:
        """Get all tracks where discovery was attempted but failed."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            query = """
                SELECT mpt.id, mpt.track_name, mpt.artist_name, mpt.album_name,
                       mpt.playlist_id, mp.name as playlist_name
                FROM mirrored_playlist_tracks mpt
                JOIN mirrored_playlists mp ON mpt.playlist_id = mp.id
                WHERE mpt.extra_data LIKE '%"discovery_attempted": true%'
                  AND mpt.extra_data NOT LIKE '%"discovered": true%'
            """
            params = []
            if playlist_id:
                query += " AND mpt.playlist_id = ?"
                params.append(playlist_id)
            elif profile_id:
                query += " AND mp.profile_id = ?"
                params.append(profile_id)
            query += " ORDER BY mp.name, mpt.track_name"
            cursor.execute(query, params)
            return [dict(row) for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting discovery pool failed: {e}")
            return []

    def delete_discovery_cache_entry(self, entry_id: int) -> bool:
        """Delete a single entry from the discovery match cache."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("DELETE FROM discovery_match_cache WHERE id = ?", (entry_id,))
            conn.commit()
            return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error deleting discovery cache entry: {e}")
            return False

    def get_discovery_pool_stats(self, profile_id: int = None) -> dict:
        """Get counts for matched and failed discovery tracks."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("SELECT COUNT(*) as cnt FROM discovery_match_cache")
            matched = cursor.fetchone()['cnt']

            query = """
                SELECT COUNT(*) as cnt FROM mirrored_playlist_tracks mpt
                JOIN mirrored_playlists mp ON mpt.playlist_id = mp.id
                WHERE mpt.extra_data LIKE '%"discovery_attempted": true%'
                  AND mpt.extra_data NOT LIKE '%"discovered": true%'
            """
            params = []
            if profile_id:
                query += " AND mp.profile_id = ?"
                params.append(profile_id)
            cursor.execute(query, params)
            failed = cursor.fetchone()['cnt']
            return {'matched': matched, 'failed': failed}
        except Exception as e:
            logger.error(f"Error getting discovery pool stats: {e}")
            return {'matched': 0, 'failed': 0}

    # ==================== Retag Tool Methods ====================

    def add_retag_group(self, group_type: str, artist_name: str, album_name: str,
                        image_url: str = None, spotify_album_id: str = None,
                        itunes_album_id: str = None, total_tracks: int = 1,
                        release_date: str = None) -> Optional[int]:
        """Insert a retag group and return its ID."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                INSERT INTO retag_groups (group_type, artist_name, album_name, image_url,
                    spotify_album_id, itunes_album_id, total_tracks, release_date)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            """, (group_type, artist_name, album_name, image_url,
                  spotify_album_id, itunes_album_id, total_tracks, release_date))
            conn.commit()
            return cursor.lastrowid
        except Exception as e:
            logger.error(f"Error adding retag group: {e}")
            return None

    def add_retag_track(self, group_id: int, track_number: int, disc_number: int,
                        title: str, file_path: str, file_format: str = None,
                        spotify_track_id: str = None, itunes_track_id: str = None) -> Optional[int]:
        """Insert a retag track record and return its ID."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                INSERT INTO retag_tracks (group_id, track_number, disc_number, title,
                    file_path, file_format, spotify_track_id, itunes_track_id)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            """, (group_id, track_number, disc_number, title, file_path,
                  file_format, spotify_track_id, itunes_track_id))
            conn.commit()
            return cursor.lastrowid
        except Exception as e:
            logger.error(f"Error adding retag track: {e}")
            return None

    def get_retag_groups(self) -> List[Dict[str, Any]]:
        """Return all retag groups ordered by artist_name, created_at DESC."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT g.*, COUNT(t.id) as track_count
                FROM retag_groups g
                LEFT JOIN retag_tracks t ON t.group_id = g.id
                GROUP BY g.id
                ORDER BY g.artist_name ASC, g.created_at DESC
            """)
            columns = [desc[0] for desc in cursor.description]
            return [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting retag groups: {e}")
            return []

    def get_retag_tracks(self, group_id: int) -> List[Dict[str, Any]]:
        """Return all tracks for a given group_id ordered by disc_number, track_number."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT * FROM retag_tracks
                WHERE group_id = ?
                ORDER BY disc_number ASC, track_number ASC
            """, (group_id,))
            columns = [desc[0] for desc in cursor.description]
            return [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting retag tracks: {e}")
            return []

    def get_retag_stats(self) -> Dict[str, int]:
        """Return retag statistics: groups, tracks, artists counts."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("SELECT COUNT(*) FROM retag_groups")
            groups = cursor.fetchone()[0]
            cursor.execute("SELECT COUNT(*) FROM retag_tracks")
            tracks = cursor.fetchone()[0]
            cursor.execute("SELECT COUNT(DISTINCT artist_name) FROM retag_groups")
            artists = cursor.fetchone()[0]
            return {"groups": groups, "tracks": tracks, "artists": artists}
        except Exception as e:
            logger.error(f"Error getting retag stats: {e}")
            return {"groups": 0, "tracks": 0, "artists": 0}

    def find_retag_group(self, artist_name: str, album_name: str) -> Optional[int]:
        """Find an existing retag group by artist + album name. Returns group ID or None."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute(
                "SELECT id FROM retag_groups WHERE artist_name = ? AND album_name = ?",
                (artist_name, album_name)
            )
            row = cursor.fetchone()
            return row[0] if row else None
        except Exception as e:
            logger.error(f"Error finding retag group: {e}")
            return None

    def retag_track_exists(self, group_id: int, file_path: str) -> bool:
        """Check if a retag track already exists for a group + file path."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute(
                "SELECT 1 FROM retag_tracks WHERE group_id = ? AND file_path = ?",
                (group_id, file_path)
            )
            return cursor.fetchone() is not None
        except Exception as e:
            logger.error(f"Error checking retag track existence: {e}")
            return False

    def update_retag_track_path(self, track_id: int, new_file_path: str) -> bool:
        """Update file_path for a retag track after re-tag move."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute(
                "UPDATE retag_tracks SET file_path = ? WHERE id = ?",
                (new_file_path, track_id)
            )
            conn.commit()
            return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error updating retag track path: {e}")
            return False

    def update_retag_group(self, group_id: int, **kwargs) -> bool:
        """Update retag group fields. Accepts keyword args for columns to update."""
        allowed = {'group_type', 'artist_name', 'album_name', 'image_url',
                    'spotify_album_id', 'itunes_album_id', 'total_tracks', 'release_date'}
        updates = {k: v for k, v in kwargs.items() if k in allowed}
        if not updates:
            return False
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            set_clause = ", ".join(f"{k} = ?" for k in updates)
            values = list(updates.values()) + [group_id]
            cursor.execute(f"UPDATE retag_groups SET {set_clause} WHERE id = ?", values)
            conn.commit()
            return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error updating retag group: {e}")
            return False

    def trim_retag_groups(self, max_groups: int = 100):
        """Remove oldest retag groups if count exceeds max_groups."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("SELECT COUNT(*) FROM retag_groups")
            count = cursor.fetchone()[0]
            if count <= max_groups:
                return
            excess = count - max_groups
            cursor.execute(
                "SELECT id FROM retag_groups ORDER BY created_at ASC LIMIT ?", (excess,)
            )
            old_ids = [row[0] for row in cursor.fetchall()]
            for gid in old_ids:
                cursor.execute("DELETE FROM retag_tracks WHERE group_id = ?", (gid,))
                cursor.execute("DELETE FROM retag_groups WHERE id = ?", (gid,))
            conn.commit()
            logger.info(f"Trimmed {len(old_ids)} oldest retag groups (cap: {max_groups})")
        except Exception as e:
            logger.error(f"Error trimming retag groups: {e}")

    def delete_retag_group(self, group_id: int) -> bool:
        """Delete a retag group and its tracks (CASCADE)."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            # Manually delete tracks first since SQLite CASCADE requires PRAGMA foreign_keys=ON
            cursor.execute("DELETE FROM retag_tracks WHERE group_id = ?", (group_id,))
            cursor.execute("DELETE FROM retag_groups WHERE id = ?", (group_id,))
            conn.commit()
            return True
        except Exception as e:
            logger.error(f"Error deleting retag group: {e}")
            return False

    def delete_all_retag_groups(self) -> int:
        """Delete all retag groups and tracks. Returns count deleted."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("SELECT COUNT(*) FROM retag_groups")
            count = cursor.fetchone()[0]
            cursor.execute("DELETE FROM retag_tracks")
            cursor.execute("DELETE FROM retag_groups")
            conn.commit()
            return count
        except Exception as e:
            logger.error(f"Error clearing all retag groups: {e}")
            return 0

    # ── Full-row API query methods (return dicts, not dataclasses) ────────

    def api_get_artist(self, artist_id: int) -> Optional[Dict[str, Any]]:
        """Get artist by ID with ALL columns as a dict."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("SELECT * FROM artists WHERE id = ?", (artist_id,))
            row = cursor.fetchone()
            return dict(row) if row else None
        except Exception as e:
            logger.error(f"API: Error getting artist {artist_id}: {e}")
            return None

    def api_get_album(self, album_id: int) -> Optional[Dict[str, Any]]:
        """Get album by ID with ALL columns as a dict."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("SELECT * FROM albums WHERE id = ?", (album_id,))
            row = cursor.fetchone()
            return dict(row) if row else None
        except Exception as e:
            logger.error(f"API: Error getting album {album_id}: {e}")
            return None

    def api_get_track(self, track_id: int) -> Optional[Dict[str, Any]]:
        """Get track by ID with ALL columns as a dict, plus artist_name and album_title."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT t.*, a.name as artist_name, al.title as album_title
                FROM tracks t
                LEFT JOIN artists a ON t.artist_id = a.id
                LEFT JOIN albums al ON t.album_id = al.id
                WHERE t.id = ?
            """, (track_id,))
            row = cursor.fetchone()
            return dict(row) if row else None
        except Exception as e:
            logger.error(f"API: Error getting track {track_id}: {e}")
            return None

    def api_get_albums_by_artist(self, artist_id: int) -> List[Dict[str, Any]]:
        """Get all albums for an artist with ALL columns."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute(
                "SELECT * FROM albums WHERE artist_id = ? ORDER BY year, title",
                (artist_id,),
            )
            return [dict(row) for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"API: Error getting albums for artist {artist_id}: {e}")
            return []

    def api_get_tracks_by_album(self, album_id: int) -> List[Dict[str, Any]]:
        """Get all tracks for an album with ALL columns, plus artist_name."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT t.*, a.name as artist_name
                FROM tracks t
                LEFT JOIN artists a ON t.artist_id = a.id
                WHERE t.album_id = ?
                ORDER BY t.track_number, t.title
            """, (album_id,))
            return [dict(row) for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"API: Error getting tracks for album {album_id}: {e}")
            return []

    def api_get_tracks_by_ids(self, track_ids: List[int]) -> List[Dict[str, Any]]:
        """Get multiple tracks by ID with ALL columns, plus artist_name and album_title."""
        if not track_ids:
            return []
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            placeholders = ",".join("?" * len(track_ids))
            cursor.execute(f"""
                SELECT t.*, a.name as artist_name, al.title as album_title
                FROM tracks t
                LEFT JOIN artists a ON t.artist_id = a.id
                LEFT JOIN albums al ON t.album_id = al.id
                WHERE t.id IN ({placeholders})
            """, track_ids)
            return [dict(row) for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"API: Error getting tracks by IDs: {e}")
            return []

    def api_lookup_by_external_id(self, table: str, provider: str, external_id: str) -> Optional[Dict[str, Any]]:
        """Look up an entity by external provider ID.

        Args:
            table: 'artists', 'albums', or 'tracks'
            provider: 'spotify', 'musicbrainz', 'itunes', 'deezer', 'audiodb',
                      'tidal', 'qobuz', 'genius' (genius: artists/tracks only)
        """
        column_map = {
            "artists": {
                "spotify": "spotify_artist_id",
                "musicbrainz": "musicbrainz_id",
                "itunes": "itunes_artist_id",
                "deezer": "deezer_id",
                "audiodb": "audiodb_id",
                "tidal": "tidal_id",
                "qobuz": "qobuz_id",
                "genius": "genius_id",
            },
            "albums": {
                "spotify": "spotify_album_id",
                "musicbrainz": "musicbrainz_release_id",
                "itunes": "itunes_album_id",
                "deezer": "deezer_id",
                "audiodb": "audiodb_id",
                "tidal": "tidal_id",
                "qobuz": "qobuz_id",
            },
            "tracks": {
                "spotify": "spotify_track_id",
                "musicbrainz": "musicbrainz_recording_id",
                "itunes": "itunes_track_id",
                "deezer": "deezer_id",
                "audiodb": "audiodb_id",
                "tidal": "tidal_id",
                "qobuz": "qobuz_id",
                "genius": "genius_id",
            },
        }
        if table not in column_map or provider not in column_map[table]:
            return None
        column = column_map[table][provider]
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute(f"SELECT * FROM {table} WHERE {column} = ?", (external_id,))
            row = cursor.fetchone()
            return dict(row) if row else None
        except Exception as e:
            logger.error(f"API: External lookup {table}.{column}={external_id}: {e}")
            return None

    def api_get_genres(self, table: str = "artists") -> List[Dict[str, Any]]:
        """Get all unique genres with counts from the given table."""
        if table not in ("artists", "albums"):
            return []
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute(f"SELECT genres FROM {table}")
            genre_counts: Dict[str, int] = {}
            for row in cursor.fetchall():
                raw = row["genres"]
                if raw:
                    try:
                        genres = json.loads(raw) if isinstance(raw, str) else raw
                        if isinstance(genres, list):
                            for g in genres:
                                g = g.strip() if isinstance(g, str) else str(g)
                                if g:
                                    genre_counts[g] = genre_counts.get(g, 0) + 1
                    except (json.JSONDecodeError, TypeError):
                        pass
            return sorted(
                [{"name": k, "count": v} for k, v in genre_counts.items()],
                key=lambda x: x["count"],
                reverse=True,
            )
        except Exception as e:
            logger.error(f"API: Error getting genres from {table}: {e}")
            return []

    # ── Library History ─────────────────────────────────────────────────

    def add_library_history_entry(self, event_type, title, artist_name=None, album_name=None,
                                  quality=None, server_source=None, file_path=None, thumb_url=None,
                                  download_source=None, source_track_id=None, source_track_title=None,
                                  source_filename=None, acoustid_result=None, source_artist=None):
        """Record a download or import event to the library history table."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                INSERT INTO library_history (event_type, title, artist_name, album_name,
                                             quality, server_source, file_path, thumb_url, download_source,
                                             source_track_id, source_track_title, source_filename,
                                             acoustid_result, source_artist)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """, (event_type, title, artist_name, album_name, quality, server_source, file_path, thumb_url,
                  download_source, source_track_id, source_track_title, source_filename,
                  acoustid_result, source_artist))
            conn.commit()
            return True
        except Exception as e:
            logger.debug(f"Error adding library history entry: {e}")
            return False

    def get_library_history(self, event_type=None, page=1, limit=50):
        """Query library history with optional type filter and pagination.

        Returns (entries_list, total_count).
        """
        try:
            conn = self._get_connection()
            cursor = conn.cursor()

            where = "WHERE event_type = ?" if event_type else ""
            params = [event_type] if event_type else []

            cursor.execute(f"SELECT COUNT(*) as cnt FROM library_history {where}", params)
            total = cursor.fetchone()['cnt']

            offset = (page - 1) * limit
            cursor.execute(f"""
                SELECT * FROM library_history {where}
                ORDER BY created_at DESC
                LIMIT ? OFFSET ?
            """, params + [limit, offset])
            entries = [dict(row) for row in cursor.fetchall()]

            return entries, total
        except Exception as e:
            logger.error(f"Error querying library history: {e}")
            return [], 0

    def get_library_history_stats(self):
        """Return counts per event_type and per download_source."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("SELECT event_type, COUNT(*) as cnt FROM library_history GROUP BY event_type")
            stats = {'downloads': 0, 'imports': 0}
            for row in cursor.fetchall():
                if row['event_type'] == 'download':
                    stats['downloads'] = row['cnt']
                elif row['event_type'] == 'import':
                    stats['imports'] = row['cnt']

            # Per-source breakdown for downloads
            source_counts = {}
            try:
                cursor.execute("""
                    SELECT download_source, COUNT(*) as cnt FROM library_history
                    WHERE event_type = 'download' AND download_source IS NOT NULL AND download_source != ''
                    GROUP BY download_source ORDER BY cnt DESC
                """)
                for row in cursor.fetchall():
                    source_counts[row['download_source']] = row['cnt']
            except Exception as e:
                logger.debug("Failed to load library history source counts: %s", e)
            stats['source_counts'] = source_counts

            return stats
        except Exception as e:
            logger.debug(f"Error getting library history stats: {e}")
            return {'downloads': 0, 'imports': 0, 'source_counts': {}}

    # ── Sync History ──────────────────────────────────────────────

    def add_sync_history_entry(self, batch_id, playlist_id, playlist_name, source, sync_type,
                               tracks_json, artist_context=None, album_context=None,
                               thumb_url=None, total_tracks=0, is_album_download=False,
                               playlist_folder_mode=False, source_page=None):
        """Record a new sync operation to sync_history."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                INSERT INTO sync_history (batch_id, playlist_id, playlist_name, source, sync_type,
                    tracks_json, artist_context, album_context, thumb_url, total_tracks,
                    is_album_download, playlist_folder_mode, source_page)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """, (batch_id, playlist_id, playlist_name, source, sync_type,
                  tracks_json, artist_context, album_context, thumb_url, total_tracks,
                  int(is_album_download), int(playlist_folder_mode), source_page))
            conn.commit()
            # Cap at 100 entries
            cursor.execute("""
                DELETE FROM sync_history WHERE id NOT IN (
                    SELECT id FROM sync_history ORDER BY started_at DESC LIMIT 100
                )
            """)
            conn.commit()
            return True
        except Exception as e:
            logger.debug(f"Error adding sync history entry: {e}")
            return False

    def update_sync_history_completion(self, batch_id, tracks_found=0, tracks_downloaded=0, tracks_failed=0):
        """Update a sync_history entry with completion stats."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                UPDATE sync_history SET tracks_found = ?, tracks_downloaded = ?,
                    tracks_failed = ?, completed_at = CURRENT_TIMESTAMP
                WHERE batch_id = ?
            """, (tracks_found, tracks_downloaded, tracks_failed, batch_id))
            conn.commit()
            return cursor.rowcount > 0
        except Exception as e:
            logger.debug(f"Error updating sync history completion: {e}")
            return False

    def update_sync_history_track_results(self, batch_id, track_results_json):
        """Store per-track match/download results on a sync_history entry."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                UPDATE sync_history SET track_results = ? WHERE batch_id = ?
            """, (track_results_json, batch_id))
            conn.commit()
            return cursor.rowcount > 0
        except Exception as e:
            logger.debug(f"Error updating sync history track results: {e}")
            return False

    def refresh_sync_history_entry(self, entry_id, tracks_found=0, tracks_downloaded=0, tracks_failed=0):
        """Update an existing sync_history entry with new stats and reset timestamps to move it to the top."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                UPDATE sync_history SET tracks_found = ?, tracks_downloaded = ?,
                    tracks_failed = ?, started_at = CURRENT_TIMESTAMP,
                    completed_at = CURRENT_TIMESTAMP
                WHERE id = ?
            """, (tracks_found, tracks_downloaded, tracks_failed, entry_id))
            conn.commit()
            return cursor.rowcount > 0
        except Exception as e:
            logger.debug(f"Error refreshing sync history entry: {e}")
            return False

    def get_sync_history(self, source=None, page=1, limit=20):
        """Return (entries, total) for sync_history, newest first. Full tracks_json excluded from list."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            where = "WHERE source = ?" if source else ""
            params = [source] if source else []

            cursor.execute(f"SELECT COUNT(*) as cnt FROM sync_history {where}", params)
            total = cursor.fetchone()['cnt']

            offset = (page - 1) * limit
            cursor.execute(f"""
                SELECT id, batch_id, playlist_id, playlist_name, source, sync_type,
                       artist_context, album_context, thumb_url, total_tracks,
                       tracks_found, tracks_downloaded, tracks_failed,
                       is_album_download, playlist_folder_mode, started_at, completed_at
                FROM sync_history {where}
                ORDER BY started_at DESC
                LIMIT ? OFFSET ?
            """, params + [limit, offset])
            entries = [dict(row) for row in cursor.fetchall()]
            return entries, total
        except Exception as e:
            logger.error(f"Error querying sync history: {e}")
            return [], 0

    def get_latest_sync_history_by_playlist(self, playlist_id):
        """Return the most recent sync_history row for a given playlist_id."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT * FROM sync_history
                WHERE playlist_id = ?
                ORDER BY started_at DESC LIMIT 1
            """, (playlist_id,))
            row = cursor.fetchone()
            return dict(row) if row else None
        except Exception as e:
            logger.debug(f"Error getting latest sync history by playlist: {e}")
            return None

    def get_sync_history_entry(self, entry_id):
        """Return a single sync_history row with full tracks_json (for re-trigger)."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("SELECT * FROM sync_history WHERE id = ?", (entry_id,))
            row = cursor.fetchone()
            return dict(row) if row else None
        except Exception as e:
            logger.error(f"Error getting sync history entry: {e}")
            return None

    def delete_sync_history_entry(self, entry_id):
        """Delete a single sync_history entry."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("DELETE FROM sync_history WHERE id = ?", (entry_id,))
            conn.commit()
            return cursor.rowcount > 0
        except Exception as e:
            logger.debug(f"Error deleting sync history entry: {e}")
            return False

    def get_sync_history_playlist_names(self):
        """Return distinct playlist names ever synced (for server playlist filtering)."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("SELECT DISTINCT playlist_name FROM sync_history WHERE playlist_name != ''")
            return [row[0] for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting sync history playlist names: {e}")
            return []

    def get_sync_history_stats(self):
        """Return counts grouped by source."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("SELECT source, COUNT(*) as cnt FROM sync_history GROUP BY source")
            return {row['source']: row['cnt'] for row in cursor.fetchall()}
        except Exception as e:
            logger.debug(f"Error getting sync history stats: {e}")
            return {}

    def get_recent_batch_history(self, days: int = 7, limit: int = 50) -> List[Dict[str, Any]]:
        """Get completed batch history from the last N days for the downloads batch panel."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                SELECT id, batch_id, playlist_name, source, sync_type, source_page,
                       total_tracks, tracks_found, tracks_downloaded, tracks_failed,
                       thumb_url, is_album_download, started_at, completed_at
                FROM sync_history
                WHERE completed_at IS NOT NULL
                  AND started_at >= datetime('now', ? || ' days')
                ORDER BY started_at DESC
                LIMIT ?
            """, (f'-{days}', limit))
            return [dict(row) for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting recent batch history: {e}")
            return []

    def api_get_recently_added(self, entity_type: str = "albums", limit: int = 50) -> List[Dict[str, Any]]:
        """Get recently added entities, ordered by created_at DESC."""
        table = {"artists": "artists", "albums": "albums", "tracks": "tracks"}.get(entity_type)
        if not table:
            return []
        try:
            conn = self._get_connection()
            cursor = conn.cursor()
            cursor.execute(f"SELECT * FROM {table} ORDER BY created_at DESC LIMIT ?", (limit,))
            return [dict(row) for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"API: Error getting recently added {entity_type}: {e}")
            return []

    def api_list_albums(self, search: str = "", artist_id: int = None,
                        year: int = None, page: int = 1, limit: int = 50) -> Dict[str, Any]:
        """List/search albums with pagination, returning full rows."""
        try:
            conn = self._get_connection()
            cursor = conn.cursor()

            where_parts = []
            params: list = []

            if search:
                where_parts.append("LOWER(al.title) LIKE LOWER(?)")
                params.append(f"%{search}%")
            if artist_id is not None:
                where_parts.append("al.artist_id = ?")
                params.append(artist_id)
            if year is not None:
                where_parts.append("al.year = ?")
                params.append(year)

            where_clause = " AND ".join(where_parts) if where_parts else "1=1"

            # Count
            cursor.execute(f"SELECT COUNT(*) as cnt FROM albums al WHERE {where_clause}", params)
            total = cursor.fetchone()["cnt"]

            # Fetch page
            offset = (page - 1) * limit
            cursor.execute(
                f"""SELECT al.*, a.name as artist_name
                    FROM albums al
                    LEFT JOIN artists a ON al.artist_id = a.id
                    WHERE {where_clause}
                    ORDER BY al.title COLLATE NOCASE
                    LIMIT ? OFFSET ?""",
                params + [limit, offset],
            )
            albums = [dict(row) for row in cursor.fetchall()]

            return {"albums": albums, "total": total}
        except Exception as e:
            logger.error(f"API: Error listing albums: {e}")
            return {"albums": [], "total": 0}

    # ── Mirrored Playlists ───────────────────────────────────────────────

    def mirror_playlist(self, source: str, source_playlist_id: str, name: str,
                        tracks: List[Dict], profile_id: int = 1, **kwargs) -> Optional[int]:
        """Upsert a mirrored playlist and replace all its tracks."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                # Upsert the playlist row
                cursor.execute("""
                    INSERT INTO mirrored_playlists
                        (source, source_playlist_id, name, description, owner, image_url, track_count, profile_id, updated_at)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
                    ON CONFLICT(source, source_playlist_id, profile_id) DO UPDATE SET
                        name = excluded.name,
                        description = excluded.description,
                        owner = excluded.owner,
                        image_url = excluded.image_url,
                        track_count = excluded.track_count,
                        updated_at = CURRENT_TIMESTAMP
                """, (
                    source, source_playlist_id, name,
                    kwargs.get('description'), kwargs.get('owner'),
                    kwargs.get('image_url'), len(tracks), profile_id
                ))
                playlist_id = cursor.execute(
                    "SELECT id FROM mirrored_playlists WHERE source=? AND source_playlist_id=? AND profile_id=?",
                    (source, source_playlist_id, profile_id)
                ).fetchone()['id']

                # Preserve existing extra_data (discovery results) before replacing tracks
                old_extra_map = {}
                try:
                    cursor.execute("""
                        SELECT source_track_id, extra_data FROM mirrored_playlist_tracks
                        WHERE playlist_id = ? AND source_track_id IS NOT NULL AND extra_data IS NOT NULL
                    """, (playlist_id,))
                    old_extra_map = {row['source_track_id']: row['extra_data'] for row in cursor.fetchall()}
                except Exception as e:
                    logger.debug("Failed to preserve mirrored playlist extra_data: %s", e)

                # Replace all tracks
                cursor.execute("DELETE FROM mirrored_playlist_tracks WHERE playlist_id=?", (playlist_id,))
                for i, t in enumerate(tracks):
                    extra = t.get('extra_data')
                    if extra and not isinstance(extra, str):
                        extra = json.dumps(extra)
                    # Restore preserved discovery data if the incoming track doesn't have its own
                    if not extra:
                        sid = t.get('source_track_id')
                        if sid and sid in old_extra_map:
                            extra = old_extra_map[sid]
                    cursor.execute("""
                        INSERT INTO mirrored_playlist_tracks
                            (playlist_id, position, track_name, artist_name, album_name, duration_ms, image_url, source_track_id, extra_data)
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
                    """, (
                        playlist_id, i + 1,
                        t.get('track_name', ''), t.get('artist_name', ''),
                        t.get('album_name', ''), t.get('duration_ms', 0),
                        t.get('image_url'), t.get('source_track_id'), extra
                    ))
                conn.commit()
                logger.info(f"Mirrored playlist '{name}' ({source}) with {len(tracks)} tracks")
                return playlist_id
        except Exception as e:
            logger.error(f"Error mirroring playlist: {e}")
            return None

    def get_mirrored_playlists(self, profile_id: int = 1) -> List[Dict]:
        """Return all mirrored playlists for a profile, newest first."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT * FROM mirrored_playlists
                    WHERE profile_id = ?
                    ORDER BY updated_at DESC
                """, (profile_id,))
                return [dict(row) for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting mirrored playlists: {e}")
            return []

    def mark_mirrored_playlist_explored(self, playlist_id: int) -> bool:
        """Set explored_at to now for a mirrored playlist."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute(
                    "UPDATE mirrored_playlists SET explored_at = CURRENT_TIMESTAMP WHERE id = ?",
                    (playlist_id,)
                )
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error marking playlist {playlist_id} as explored: {e}")
            return False

    def get_mirrored_playlist(self, playlist_id: int) -> Optional[Dict]:
        """Return a single mirrored playlist by id."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("SELECT * FROM mirrored_playlists WHERE id = ?", (playlist_id,))
                row = cursor.fetchone()
                return dict(row) if row else None
        except Exception as e:
            logger.error(f"Error getting mirrored playlist: {e}")
            return None

    def get_mirrored_playlist_tracks(self, playlist_id: int) -> List[Dict]:
        """Return all tracks for a mirrored playlist ordered by position."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT * FROM mirrored_playlist_tracks
                    WHERE playlist_id = ?
                    ORDER BY position
                """, (playlist_id,))
                return [dict(row) for row in cursor.fetchall()]
        except Exception as e:
            logger.error(f"Error getting mirrored playlist tracks: {e}")
            return []

    def update_mirrored_track_extra_data(self, track_id: int, extra_data_dict: dict) -> bool:
        """Merge new data into a mirrored track's extra_data JSON field."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute(
                    "SELECT extra_data FROM mirrored_playlist_tracks WHERE id = ?",
                    (track_id,)
                )
                row = cursor.fetchone()
                if not row:
                    return False
                existing = {}
                if row['extra_data']:
                    try:
                        existing = json.loads(row['extra_data'])
                    except (json.JSONDecodeError, TypeError):
                        pass
                existing.update(extra_data_dict)
                cursor.execute(
                    "UPDATE mirrored_playlist_tracks SET extra_data = ? WHERE id = ?",
                    (json.dumps(existing), track_id)
                )
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error updating mirrored track extra_data: {e}")
            return False

    def get_mirrored_tracks_extra_data_map(self, playlist_id: int) -> dict:
        """Return {source_track_id: extra_data_json_string} for a playlist.
        Used to preserve discovery data across refreshes."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT source_track_id, extra_data FROM mirrored_playlist_tracks
                    WHERE playlist_id = ? AND source_track_id IS NOT NULL AND extra_data IS NOT NULL
                """, (playlist_id,))
                return {row['source_track_id']: row['extra_data'] for row in cursor.fetchall()}
        except Exception as e:
            logger.error(f"Error getting extra_data map: {e}")
            return {}

    def clear_mirrored_playlist_discovery(self, playlist_id: int) -> int:
        """Clear extra_data for all tracks in a mirrored playlist (resets discovery)."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute(
                    "UPDATE mirrored_playlist_tracks SET extra_data = NULL WHERE playlist_id = ?",
                    (playlist_id,)
                )
                conn.commit()
                return cursor.rowcount
        except Exception as e:
            logger.error(f"Error clearing mirrored playlist discovery: {e}")
            return 0

    def get_mirrored_playlist_discovery_counts(self, playlist_id: int) -> tuple:
        """Return (discovered_count, total_count) for a mirrored playlist."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute(
                    "SELECT COUNT(*) as total FROM mirrored_playlist_tracks WHERE playlist_id = ?",
                    (playlist_id,)
                )
                total = cursor.fetchone()['total']
                cursor.execute(
                    "SELECT COUNT(*) as discovered FROM mirrored_playlist_tracks WHERE playlist_id = ? AND extra_data LIKE '%\"discovered\": true%'",
                    (playlist_id,)
                )
                discovered = cursor.fetchone()['discovered']
                return (discovered, total)
        except Exception as e:
            logger.error(f"Error getting mirrored playlist discovery counts: {e}")
            return (0, 0)

    def get_mirrored_playlist_status_counts(self, playlist_id: int) -> dict:
        """Return discovery, wishlisted, and downloaded counts for a mirrored playlist.
        Discovery counts are critical (same as old method). Library/wishlist counts are
        best-effort extras that won't break discovery detection if they fail."""
        result = {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0}
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Core counts — same reliable queries as get_mirrored_playlist_discovery_counts
                cursor.execute(
                    "SELECT COUNT(*) as total FROM mirrored_playlist_tracks WHERE playlist_id = ?",
                    (playlist_id,)
                )
                result['total'] = cursor.fetchone()['total']
                cursor.execute(
                    "SELECT COUNT(*) as discovered FROM mirrored_playlist_tracks WHERE playlist_id = ? AND extra_data LIKE '%\"discovered\": true%'",
                    (playlist_id,)
                )
                result['discovered'] = cursor.fetchone()['discovered']

                # Best-effort extras — won't break if tracks table has issues
                try:
                    cursor.execute("""
                        SELECT
                            SUM(CASE WHEN mpt.source_track_id IS NOT NULL AND mpt.source_track_id != ''
                                 AND EXISTS (SELECT 1 FROM wishlist_tracks wt
                                             WHERE wt.spotify_track_id = mpt.source_track_id)
                                 THEN 1 ELSE 0 END) as wishlisted,
                            SUM(CASE WHEN EXISTS (SELECT 1 FROM tracks t
                                                  WHERE t.title = mpt.track_name COLLATE NOCASE
                                                    AND t.artist = mpt.artist_name COLLATE NOCASE)
                                 THEN 1 ELSE 0 END) as in_library
                        FROM mirrored_playlist_tracks mpt
                        WHERE mpt.playlist_id = ?
                    """, (playlist_id,))
                    row = cursor.fetchone()
                    result['wishlisted'] = row['wishlisted'] or 0
                    result['in_library'] = row['in_library'] or 0
                except Exception as extra_err:
                    logger.debug(f"Optional status counts failed for playlist {playlist_id}: {extra_err}")

        except Exception as e:
            logger.error(f"Error getting mirrored playlist status counts: {e}")
        return result

    def delete_mirrored_playlist(self, playlist_id: int) -> bool:
        """Delete a mirrored playlist and its tracks (CASCADE)."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("DELETE FROM mirrored_playlists WHERE id = ?", (playlist_id,))
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error deleting mirrored playlist: {e}")
            return False

    # ===========================
    # AUTOMATIONS CRUD
    # ===========================

    def create_automation(self, name: str, trigger_type: str, trigger_config: str,
                          action_type: str, action_config: str, profile_id: int = 1,
                          notify_type: str = None, notify_config: str = '{}',
                          then_actions: str = '[]', group_name: str = None):
        """Create a new automation. Returns the new automation ID or None."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """, (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name))
                conn.commit()
                return cursor.lastrowid
        except Exception as e:
            logger.error(f"Error creating automation: {e}")
            return None

    def get_automations(self, profile_id: int = 1):
        """Get all automations for a profile (includes system automations regardless of profile)."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT * FROM automations WHERE profile_id = ? OR is_system = 1 ORDER BY is_system DESC, created_at DESC
                """, (profile_id,))
                rows = cursor.fetchall()
                return [dict(row) for row in rows]
        except Exception as e:
            logger.error(f"Error getting automations: {e}")
            return []

    def get_system_automation_by_action(self, action_type: str):
        """Get a system automation by its action_type. Returns dict or None."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("SELECT * FROM automations WHERE is_system = 1 AND action_type = ?", (action_type,))
                row = cursor.fetchone()
                return dict(row) if row else None
        except Exception as e:
            logger.error(f"Error getting system automation for {action_type}: {e}")
            return None

    def get_automation(self, automation_id: int):
        """Get a single automation by ID."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("SELECT * FROM automations WHERE id = ?", (automation_id,))
                row = cursor.fetchone()
                return dict(row) if row else None
        except Exception as e:
            logger.error(f"Error getting automation {automation_id}: {e}")
            return None

    def update_automation(self, automation_id: int, **kwargs) -> bool:
        """Update automation fields."""
        allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions', 'group_name'}
        updates = {k: v for k, v in kwargs.items() if k in allowed}
        if not updates:
            return False
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                set_clause = ', '.join(f"{k} = ?" for k in updates)
                values = list(updates.values()) + [automation_id]
                cursor.execute(
                    f"UPDATE automations SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
                    values
                )
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error updating automation {automation_id}: {e}")
            return False

    def delete_automation(self, automation_id: int) -> bool:
        """Delete an automation. System automations cannot be deleted."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("SELECT is_system FROM automations WHERE id = ?", (automation_id,))
                row = cursor.fetchone()
                if row and row['is_system']:
                    logger.warning(f"Attempted to delete system automation {automation_id}")
                    return False
                cursor.execute("DELETE FROM automations WHERE id = ?", (automation_id,))
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error deleting automation {automation_id}: {e}")
            return False

    def batch_update_group(self, automation_ids: list, group_name: str = None) -> int:
        """Batch update group_name for multiple automations. Excludes system automations."""
        if not automation_ids:
            return 0
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                placeholders = ','.join('?' for _ in automation_ids)
                cursor.execute(
                    f"UPDATE automations SET group_name = ?, updated_at = CURRENT_TIMESTAMP "
                    f"WHERE id IN ({placeholders}) AND (is_system IS NULL OR is_system = 0)",
                    [group_name] + list(automation_ids)
                )
                conn.commit()
                return cursor.rowcount
        except Exception as e:
            logger.error(f"Error batch updating group: {e}")
            return 0

    def bulk_set_enabled(self, automation_ids: list, enabled: bool) -> int:
        """Bulk enable/disable multiple automations. Excludes system automations."""
        if not automation_ids:
            return 0
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                placeholders = ','.join('?' for _ in automation_ids)
                cursor.execute(
                    f"UPDATE automations SET enabled = ?, updated_at = CURRENT_TIMESTAMP "
                    f"WHERE id IN ({placeholders}) AND (is_system IS NULL OR is_system = 0)",
                    [1 if enabled else 0] + list(automation_ids)
                )
                conn.commit()
                return cursor.rowcount
        except Exception as e:
            logger.error(f"Error bulk toggling automations: {e}")
            return 0

    def toggle_automation(self, automation_id: int) -> bool:
        """Toggle the enabled state of an automation. Returns True on success."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute(
                    "UPDATE automations SET enabled = CASE WHEN enabled = 1 THEN 0 ELSE 1 END, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
                    (automation_id,)
                )
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error toggling automation {automation_id}: {e}")
            return False

    def update_automation_run(self, automation_id: int, next_run=None, error=None, last_result=None) -> bool:
        """Record a run: set last_run=now, increment run_count, optionally set next_run, last_error, last_result."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    UPDATE automations
                    SET last_run = CURRENT_TIMESTAMP,
                        run_count = run_count + 1,
                        next_run = ?,
                        last_error = ?,
                        last_result = ?,
                        updated_at = CURRENT_TIMESTAMP
                    WHERE id = ?
                """, (next_run, error, last_result, automation_id))
                conn.commit()
                return cursor.rowcount > 0
        except Exception as e:
            logger.error(f"Error updating automation run {automation_id}: {e}")
            return False

    def insert_automation_run_history(self, automation_id, started_at, finished_at,
                                       duration_seconds, status, summary=None,
                                       result_json=None, log_lines=None):
        """Insert a run history entry and enforce 100-row retention cap per automation."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    INSERT INTO automation_run_history
                    (automation_id, started_at, finished_at, duration_seconds, status, summary, result_json, log_lines)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """, (automation_id, started_at, finished_at, duration_seconds,
                      status, summary, result_json, log_lines))
                # Retention: keep only the newest 100 rows per automation
                cursor.execute("""
                    DELETE FROM automation_run_history
                    WHERE automation_id = ? AND id NOT IN (
                        SELECT id FROM automation_run_history
                        WHERE automation_id = ?
                        ORDER BY id DESC LIMIT 100
                    )
                """, (automation_id, automation_id))
                conn.commit()
                return True
        except Exception as e:
            logger.error(f"Error inserting automation run history for {automation_id}: {e}")
            return False

    def get_automation_run_history(self, automation_id, limit=50, offset=0):
        """Get run history for an automation, newest first."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute(
                    "SELECT COUNT(*) FROM automation_run_history WHERE automation_id = ?",
                    (automation_id,))
                total = cursor.fetchone()[0]
                cursor.execute("""
                    SELECT id, automation_id, started_at, finished_at, duration_seconds,
                           status, summary, result_json, log_lines
                    FROM automation_run_history
                    WHERE automation_id = ?
                    ORDER BY id DESC
                    LIMIT ? OFFSET ?
                """, (automation_id, limit, offset))
                cols = [d[0] for d in cursor.description]
                rows = [dict(zip(cols, row, strict=False)) for row in cursor.fetchall()]
                return {'history': rows, 'total': total}
        except Exception as e:
            logger.error(f"Error getting automation run history for {automation_id}: {e}")
            return {'history': [], 'total': 0}

    def clear_automation_run_history(self, automation_id=None):
        """Clear run history for a specific automation or all automations."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                if automation_id:
                    cursor.execute("DELETE FROM automation_run_history WHERE automation_id = ?",
                                   (automation_id,))
                else:
                    cursor.execute("DELETE FROM automation_run_history")
                conn.commit()
                return cursor.rowcount
        except Exception as e:
            logger.error(f"Error clearing automation run history: {e}")
            return 0

    def get_radio_tracks(self, track_id, limit=20, exclude_ids=None) -> Dict[str, Any]:
        """Find similar tracks for radio mode auto-play queue.

        Strategy (each tier capped to ensure diversity):
          1. Same artist, different albums (max 30% of limit)
          2. Same genre — from album genres + artist genres (other artists)
          3. Same mood / style — from album + artist metadata
          4. Random library tracks (fallback)

        Args:
            track_id: The seed track ID.
            limit: Maximum number of tracks to return.
            exclude_ids: Optional list of track IDs to exclude.

        Returns:
            dict with ``success``, ``tracks`` list.
        """
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Resolve the seed track and its album / artist
                cursor.execute("""
                    SELECT t.id, t.artist_id, t.album_id,
                           al.genres  AS album_genres,
                           al.mood    AS album_mood,
                           al.style   AS album_style,
                           ar.name    AS artist_name,
                           ar.genres  AS artist_genres,
                           ar.mood    AS artist_mood,
                           ar.style   AS artist_style
                    FROM tracks t
                    JOIN albums al ON al.id = t.album_id
                    JOIN artists ar ON ar.id = t.artist_id
                    WHERE t.id = ?
                """, (track_id,))
                seed = cursor.fetchone()
                if not seed:
                    return {'success': False, 'error': f'Track {track_id} not found'}

                seed = dict(seed)
                artist_name = seed['artist_name']

                # Build the set of IDs to exclude (seed + caller-supplied)
                excluded = {str(track_id)}
                if exclude_ids:
                    excluded.update(str(eid) for eid in exclude_ids)

                collected: list[dict] = []
                seen_ids: set[str] = set(excluded)

                def _exclude_placeholders():
                    return ','.join('?' * len(seen_ids))

                def _exclude_values():
                    return list(seen_ids)

                _track_select = """
                    SELECT t.id, t.title, t.track_number, t.duration,
                           t.file_path, t.bitrate,
                           t.album_id, t.artist_id,
                           al.title   AS album,
                           COALESCE(al.thumb_url, ar.thumb_url) AS image_url,
                           ar.name    AS artist
                    FROM tracks t
                    JOIN albums al ON al.id = t.album_id
                    JOIN artists ar ON ar.id = t.artist_id
                """
                # Only return tracks that have actual files on disk
                _file_filter = "t.file_path IS NOT NULL AND t.file_path != ''"

                def _collect(rows, cap=None):
                    """Append rows to collected. Stop at cap or limit."""
                    target = min(limit, (len(collected) + cap)) if cap else limit
                    for row in rows:
                        r = dict(row)
                        rid = str(r['id'])
                        if rid not in seen_ids:
                            seen_ids.add(rid)
                            collected.append(r)
                            if len(collected) >= target:
                                return True
                    return len(collected) >= limit

                def _parse_tags(raw_val):
                    """Parse a JSON array or comma-separated string into a list."""
                    if not raw_val:
                        return []
                    try:
                        parsed = json.loads(raw_val)
                        return parsed if isinstance(parsed, list) else [str(parsed)]
                    except (json.JSONDecodeError, ValueError):
                        return [t.strip() for t in raw_val.split(',') if t.strip()]

                # --- 1. Same artist, different albums (capped at 30% of limit) ---
                same_artist_cap = max(5, limit * 3 // 10)
                cursor.execute(f"""
                    {_track_select}
                    WHERE {_file_filter} AND ar.name = ? AND t.album_id != ? AND t.id NOT IN ({_exclude_placeholders()})
                    ORDER BY RANDOM()
                    LIMIT ?
                """, [artist_name, seed['album_id']] + _exclude_values() + [same_artist_cap])
                _collect(cursor.fetchall(), cap=same_artist_cap)

                if len(collected) >= limit:
                    return {'success': True, 'tracks': collected}

                # --- 2. Same genre (album genres + artist genres, other artists) ---
                genre_list = _parse_tags(seed.get('album_genres'))
                artist_genre_list = _parse_tags(seed.get('artist_genres'))
                all_genres = list(dict.fromkeys(genre_list + artist_genre_list))  # dedupe, preserve order

                if all_genres:
                    genre_conditions = ' OR '.join(
                        ['al.genres LIKE ?' for _ in all_genres] +
                        ['ar.genres LIKE ?' for _ in all_genres]
                    )
                    genre_params = [f'%{g}%' for g in all_genres] * 2
                    cursor.execute(f"""
                        {_track_select}
                        WHERE {_file_filter} AND ({genre_conditions})
                          AND ar.name != ?
                          AND t.id NOT IN ({_exclude_placeholders()})
                        ORDER BY RANDOM()
                        LIMIT ?
                    """, genre_params + [artist_name] + _exclude_values() + [limit - len(collected)])
                    if _collect(cursor.fetchall()):
                        return {'success': True, 'tracks': collected}

                # --- 3. Same mood / style (album + artist level) ---
                for field_name in ('mood', 'style'):
                    album_tags = _parse_tags(seed.get(f'album_{field_name}'))
                    artist_tags = _parse_tags(seed.get(f'artist_{field_name}'))
                    all_tags = list(dict.fromkeys(album_tags + artist_tags))

                    if all_tags:
                        tag_conditions = ' OR '.join(
                            [f'al.{field_name} LIKE ?' for _ in all_tags] +
                            [f'ar.{field_name} LIKE ?' for _ in all_tags]
                        )
                        tag_params = [f'%{t}%' for t in all_tags] * 2
                        cursor.execute(f"""
                            {_track_select}
                            WHERE {_file_filter} AND ({tag_conditions})
                              AND ar.name != ?
                              AND t.id NOT IN ({_exclude_placeholders()})
                            ORDER BY RANDOM()
                            LIMIT ?
                        """, tag_params + [artist_name] + _exclude_values() + [limit - len(collected)])
                        if _collect(cursor.fetchall()):
                            return {'success': True, 'tracks': collected}

                # --- 4. Random library tracks ---
                if len(collected) < limit:
                    cursor.execute(f"""
                        {_track_select}
                        WHERE {_file_filter} AND t.id NOT IN ({_exclude_placeholders()})
                        ORDER BY RANDOM()
                        LIMIT ?
                    """, _exclude_values() + [limit - len(collected)])
                    _collect(cursor.fetchall())

                return {'success': True, 'tracks': collected}

        except Exception as e:
            logger.error(f"Error getting radio tracks for track {track_id}: {e}")
            return {'success': False, 'error': str(e)}

    # ── Library Issues CRUD ──

    def create_issue(self, profile_id: int, entity_type: str, entity_id: str,
                     category: str, title: str, description: str = '',
                     snapshot_data: Dict = None, priority: str = 'normal') -> Dict[str, Any]:
        """Create a new library issue report."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    INSERT INTO library_issues
                    (profile_id, entity_type, entity_id, category, title, description,
                     snapshot_data, priority)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """, (profile_id, entity_type, entity_id, category, title, description,
                      json.dumps(snapshot_data or {}), priority))
                conn.commit()
                return {'success': True, 'id': cursor.lastrowid}
        except Exception as e:
            logger.error(f"Error creating issue: {e}")
            return {'success': False, 'error': str(e)}

    def get_issues(self, profile_id: int = None, status: str = None,
                   category: str = None, entity_type: str = None,
                   limit: int = 100, offset: int = 0,
                   is_admin: bool = False) -> Dict[str, Any]:
        """Get issues with optional filters. Non-admin only sees own issues."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                conditions = []
                params = []

                if not is_admin and profile_id:
                    conditions.append("i.profile_id = ?")
                    params.append(profile_id)
                if status:
                    conditions.append("i.status = ?")
                    params.append(status)
                if category:
                    conditions.append("i.category = ?")
                    params.append(category)
                if entity_type:
                    conditions.append("i.entity_type = ?")
                    params.append(entity_type)

                where = f"WHERE {' AND '.join(conditions)}" if conditions else ""

                # Count total
                cursor.execute(f"SELECT COUNT(*) FROM library_issues i {where}", params)
                total = cursor.fetchone()[0]

                # Fetch issues with reporter profile info
                cursor.execute(f"""
                    SELECT i.*, p.name as reporter_name, p.avatar_color as reporter_color,
                           p.avatar_url as reporter_avatar
                    FROM library_issues i
                    LEFT JOIN profiles p ON i.profile_id = p.id
                    {where}
                    ORDER BY
                        CASE i.status WHEN 'open' THEN 0 WHEN 'in_progress' THEN 1 ELSE 2 END,
                        CASE i.priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END,
                        i.created_at DESC
                    LIMIT ? OFFSET ?
                """, params + [limit, offset])

                issues = []
                for row in cursor.fetchall():
                    issue = dict(row)
                    try:
                        issue['snapshot_data'] = json.loads(issue.get('snapshot_data', '{}'))
                    except (json.JSONDecodeError, TypeError):
                        issue['snapshot_data'] = {}
                    issues.append(issue)

                return {'success': True, 'issues': issues, 'total': total}
        except Exception as e:
            logger.error(f"Error getting issues: {e}")
            return {'success': False, 'error': str(e), 'issues': [], 'total': 0}

    def get_issue(self, issue_id: int) -> Optional[Dict[str, Any]]:
        """Get a single issue by ID with reporter info."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("""
                    SELECT i.*, p.name as reporter_name, p.avatar_color as reporter_color,
                           p.avatar_url as reporter_avatar
                    FROM library_issues i
                    LEFT JOIN profiles p ON i.profile_id = p.id
                    WHERE i.id = ?
                """, (issue_id,))
                row = cursor.fetchone()
                if not row:
                    return None
                issue = dict(row)
                try:
                    issue['snapshot_data'] = json.loads(issue.get('snapshot_data', '{}'))
                except (json.JSONDecodeError, TypeError):
                    issue['snapshot_data'] = {}
                return issue
        except Exception as e:
            logger.error(f"Error getting issue {issue_id}: {e}")
            return None

    def update_issue(self, issue_id: int, updates: Dict[str, Any]) -> Dict[str, Any]:
        """Update an issue (admin response, status change, etc.)."""
        allowed_fields = {'status', 'priority', 'admin_response', 'resolved_by', 'resolved_at',
                          'title', 'description', 'category'}
        valid = {k: v for k, v in updates.items() if k in allowed_fields}
        if not valid:
            return {'success': False, 'error': 'No valid fields to update'}
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                set_clause = ', '.join(f'{k} = ?' for k in valid)
                values = list(valid.values()) + [issue_id]
                cursor.execute(
                    f"UPDATE library_issues SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
                    values
                )
                conn.commit()
                if cursor.rowcount == 0:
                    return {'success': False, 'error': 'Issue not found'}
                return {'success': True}
        except Exception as e:
            logger.error(f"Error updating issue {issue_id}: {e}")
            return {'success': False, 'error': str(e)}

    def delete_issue(self, issue_id: int) -> Dict[str, Any]:
        """Delete an issue (admin only)."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute("DELETE FROM library_issues WHERE id = ?", (issue_id,))
                conn.commit()
                if cursor.rowcount == 0:
                    return {'success': False, 'error': 'Issue not found'}
                return {'success': True}
        except Exception as e:
            logger.error(f"Error deleting issue {issue_id}: {e}")
            return {'success': False, 'error': str(e)}

    def get_issue_counts(self, is_admin: bool = False, profile_id: int = None) -> Dict[str, int]:
        """Get issue counts by status for badge display."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                profile_filter = ""
                params = []
                if not is_admin and profile_id:
                    profile_filter = "WHERE profile_id = ?"
                    params = [profile_id]
                cursor.execute(f"""
                    SELECT status, COUNT(*) as count
                    FROM library_issues
                    {profile_filter}
                    GROUP BY status
                """, params)
                counts = {'open': 0, 'in_progress': 0, 'resolved': 0, 'dismissed': 0, 'total': 0}
                for row in cursor.fetchall():
                    counts[row['status']] = row['count']
                    counts['total'] += row['count']
                return counts
        except Exception as e:
            logger.error(f"Error getting issue counts: {e}")
            return {'open': 0, 'in_progress': 0, 'resolved': 0, 'dismissed': 0, 'total': 0}

    # ===================== HiFi Instances =====================

    def _ensure_hifi_instances_table(self, cursor) -> None:
        """Defensive lazy-create. Issue #503: some users hit a "no such
        table: hifi_instances" error when adding a HiFi instance even
        though ``_initialize_database`` runs ``CREATE TABLE IF NOT EXISTS``
        on every boot. Root cause: the bulk init runs every CREATE +
        every migration inside one transaction, so if any later migration
        step throws on the user's specific DB shape, the whole batch
        rolls back (Python's sqlite3 module doesn't autocommit DDL by
        default) and ``hifi_instances`` never lands. This helper ensures
        the table exists immediately before every operation that touches
        it — idempotent, costs one PRAGMA-level no-op when the table is
        already present, and fully recovers from a broken init."""
        cursor.execute("""
            CREATE TABLE IF NOT EXISTS hifi_instances (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                url TEXT NOT NULL UNIQUE,
                priority INTEGER NOT NULL DEFAULT 0,
                enabled INTEGER NOT NULL DEFAULT 1,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        """)

    def get_hifi_instances(self) -> List[Dict[str, Any]]:
        """Get all enabled HiFi instances ordered by priority."""
        conn = self._get_connection()
        cursor = conn.cursor()
        self._ensure_hifi_instances_table(cursor)
        cursor.execute("SELECT url, priority, enabled FROM hifi_instances WHERE enabled = 1 ORDER BY priority ASC, id ASC")
        return [dict(row) for row in cursor.fetchall()]

    def get_all_hifi_instances(self) -> List[Dict[str, Any]]:
        """Get all HiFi instances (including disabled) ordered by priority."""
        conn = self._get_connection()
        cursor = conn.cursor()
        self._ensure_hifi_instances_table(cursor)
        cursor.execute("SELECT url, priority, enabled FROM hifi_instances ORDER BY priority ASC, id ASC")
        return [dict(row) for row in cursor.fetchall()]

    def add_hifi_instance(self, url: str, priority: int = 0) -> bool:
        """Add a new HiFi instance."""
        conn = self._get_connection()
        cursor = conn.cursor()
        self._ensure_hifi_instances_table(cursor)
        cursor.execute(
            "INSERT OR IGNORE INTO hifi_instances (url, priority, enabled) VALUES (?, ?, 1)",
            (url, priority)
        )
        conn.commit()
        return cursor.rowcount > 0

    def remove_hifi_instance(self, url: str) -> bool:
        """Remove a HiFi instance."""
        conn = self._get_connection()
        cursor = conn.cursor()
        self._ensure_hifi_instances_table(cursor)
        cursor.execute("DELETE FROM hifi_instances WHERE url = ?", (url,))
        conn.commit()
        return cursor.rowcount > 0

    def toggle_hifi_instance(self, url: str, enabled: bool) -> bool:
        """Enable or disable a HiFi instance."""
        conn = self._get_connection()
        cursor = conn.cursor()
        self._ensure_hifi_instances_table(cursor)
        cursor.execute("UPDATE hifi_instances SET enabled = ? WHERE url = ?", (1 if enabled else 0, url))
        conn.commit()
        return cursor.rowcount > 0

    def reorder_hifi_instances(self, urls: List[str]) -> bool:
        """Update priorities based on the given URL order.
        Returns False if any URL does not exist in the database.
        """
        if not urls:
            return True
        conn = self._get_connection()
        cursor = conn.cursor()
        self._ensure_hifi_instances_table(cursor)
        placeholders = ",".join("?" for _ in urls)
        cursor.execute(
            f"SELECT url FROM hifi_instances WHERE url IN ({placeholders})",
            urls
        )
        existing = {row["url"] for row in cursor.fetchall()}
        missing = [u for u in urls if u not in existing]
        if missing:
            return False
        for i, url in enumerate(urls):
            cursor.execute("UPDATE hifi_instances SET priority = ? WHERE url = ?", (i, url))
        conn.commit()
        return True

    def seed_hifi_instances(self, default_urls: List[str]) -> None:
        """Insert default instances if the table is empty."""
        conn = self._get_connection()
        cursor = conn.cursor()
        self._ensure_hifi_instances_table(cursor)
        cursor.execute("SELECT COUNT(*) as cnt FROM hifi_instances")
        count = cursor.fetchone()['cnt']
        if count == 0:
            for i, url in enumerate(default_urls):
                cursor.execute(
                    "INSERT OR IGNORE INTO hifi_instances (url, priority, enabled) VALUES (?, ?, 1)",
                    (url, i)
                )
            conn.commit()
            logger.info(f"Seeded {len(default_urls)} default HiFi instances")

# Thread-safe singleton pattern for database access
_database_instances: Dict[int, MusicDatabase] = {}  # Thread ID -> Database instance
_database_lock = threading.Lock()

def get_database(database_path: str = None) -> MusicDatabase:
    """Get thread-local database instance

    Args:
        database_path: Path to database file. If None or default path, uses DATABASE_PATH env var
                      or defaults to "database/music_library.db". Custom paths are used as-is.
    """
    # Use env var if path is None OR if it's the default path
    # This ensures Docker containers use the correct mounted volume location
    if database_path is None or database_path == "database/music_library.db":
        database_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')

    thread_id = threading.get_ident()

    with _database_lock:
        if thread_id not in _database_instances:
            _database_instances[thread_id] = MusicDatabase(database_path)
        return _database_instances[thread_id]

def close_database():
    """Close database instances (safe to call from any thread)"""
    global _database_instances
    
    with _database_lock:
        # Close all database instances
        for _thread_id, db_instance in list(_database_instances.items()):
            try:
                db_instance.close()
            except Exception as e:
                # Ignore threading errors during shutdown
                logger.debug("db instance close: %s", e)
        _database_instances.clear()
