From 4fee005dee8694d13def09285d974ae640cfb131 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:46:08 -0800 Subject: [PATCH] Add multi-profile support with Netflix-style profile picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow multiple users to share a single SoulSync instance with isolated personal data. Each profile gets its own watchlist, wishlist, discovery pool, similar artists, and bubble snapshots — while sharing the same music library, database, and service credentials. - Netflix-style profile picker on startup when multiple profiles exist - Optional PIN protection per profile; admin PIN required when >1 profiles - Admin-only profile management (create, edit, rename, delete) - Profile avatar images via URL with colored-initial fallback - Zero-downtime SQLite migration — all existing data maps to auto-created admin profile - Single-user installs see no changes — profile system is invisible until a second profile is created - WebSocket count emitters scoped to profile rooms (watchlist/wishlist) - Background scanners (watchlist, wishlist, discovery) iterate all profiles --- core/watchlist_scanner.py | 94 ++-- core/wishlist_service.py | 53 +- database/music_database.py | 994 +++++++++++++++++++++++++++++++------ web_server.py | 613 +++++++++++++++++------ webui/index.html | 61 +++ webui/static/mobile.css | 21 + webui/static/script.js | 574 ++++++++++++++++++++- webui/static/style.css | 479 +++++++++++++++++- 8 files changed, 2514 insertions(+), 375 deletions(-) diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index d7fa9d77..e5b9b53d 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -686,13 +686,14 @@ class WatchlistScanner: # Check if we have fresh similar artists cached (< 30 days old) # If Spotify is authenticated, also require Spotify IDs to be present spotify_authenticated = self.spotify_client and self.spotify_client.is_spotify_authenticated() - if self.database.has_fresh_similar_artists(source_artist_id, days_threshold=30, require_spotify=spotify_authenticated): + artist_profile_id = getattr(watchlist_artist, 'profile_id', 1) + if self.database.has_fresh_similar_artists(source_artist_id, days_threshold=30, require_spotify=spotify_authenticated, profile_id=artist_profile_id): logger.info(f"Similar artists for {watchlist_artist.artist_name} are cached and fresh, skipping MusicMap fetch") # Even if cached, backfill missing iTunes IDs (seamless dual-source support) - self._backfill_similar_artists_itunes_ids(source_artist_id) + self._backfill_similar_artists_itunes_ids(source_artist_id, profile_id=artist_profile_id) else: logger.info(f"Fetching similar artists for {watchlist_artist.artist_name}...") - self.update_similar_artists(watchlist_artist) + self.update_similar_artists(watchlist_artist, profile_id=artist_profile_id) logger.info(f"Similar artists updated for {watchlist_artist.artist_name}") except Exception as similar_error: logger.warning(f"Failed to update similar artists for {watchlist_artist.artist_name}: {similar_error}") @@ -1183,7 +1184,7 @@ class WatchlistScanner: 'is_local': False } - # Add to wishlist with watchlist context + # Add to wishlist with watchlist context (scoped to artist's profile) success = self.database.add_to_wishlist( spotify_track_data=spotify_track_data, failure_reason="Missing from library (found by watchlist scan)", @@ -1193,7 +1194,8 @@ class WatchlistScanner: 'watchlist_artist_id': watchlist_artist.spotify_artist_id, 'album_name': album_name, 'scan_timestamp': datetime.now().isoformat() - } + }, + profile_id=getattr(watchlist_artist, 'profile_id', 1) ) if success: @@ -1407,20 +1409,21 @@ class WatchlistScanner: logger.error(f"Error fetching similar artists from MusicMap: {e}") return [] - def _backfill_similar_artists_itunes_ids(self, source_artist_id: str) -> int: + def _backfill_similar_artists_itunes_ids(self, source_artist_id: str, profile_id: int = 1) -> int: """ Backfill missing iTunes IDs for cached similar artists. This ensures seamless dual-source support without clearing cached data. Args: source_artist_id: The source artist ID to backfill similar artists for + profile_id: Profile to scope the backfill to Returns: Number of similar artists updated with iTunes IDs """ try: # Get similar artists that are missing iTunes IDs - similar_artists = self.database.get_similar_artists_missing_itunes_ids(source_artist_id) + similar_artists = self.database.get_similar_artists_missing_itunes_ids(source_artist_id, profile_id=profile_id) if not similar_artists: return 0 @@ -1455,7 +1458,7 @@ class WatchlistScanner: logger.error(f"Error backfilling similar artists iTunes IDs: {e}") return 0 - def update_similar_artists(self, watchlist_artist: WatchlistArtist, limit: int = 10) -> bool: + def update_similar_artists(self, watchlist_artist: WatchlistArtist, limit: int = 10, profile_id: int = 1) -> bool: """ Fetch and store similar artists for a watchlist artist. Called after each artist scan to build discovery pool. @@ -1486,7 +1489,8 @@ class WatchlistScanner: similar_artist_name=similar_artist['name'], similar_artist_spotify_id=similar_artist.get('spotify_id'), similar_artist_itunes_id=similar_artist.get('itunes_id'), - similarity_rank=rank + similarity_rank=rank, + profile_id=profile_id ) if success: @@ -1504,7 +1508,7 @@ class WatchlistScanner: logger.error(f"Error fetching similar artists for {watchlist_artist.artist_name}: {e}") return False - def populate_discovery_pool(self, top_artists_limit: int = 50, albums_per_artist: int = 10): + def populate_discovery_pool(self, top_artists_limit: int = 50, albums_per_artist: int = 10, profile_id: int = 1): """ Populate discovery pool with tracks from top similar artists. Called after watchlist scan completes. @@ -1520,14 +1524,14 @@ class WatchlistScanner: import random # Check if we should run discovery pool population (prevents over-polling) - skip_pool_population = not self.database.should_populate_discovery_pool(hours_threshold=24) + skip_pool_population = not self.database.should_populate_discovery_pool(hours_threshold=24, profile_id=profile_id) if skip_pool_population: logger.info("Discovery pool was populated recently (< 24 hours ago). Skipping pool population.") logger.info("But still refreshing recent albums cache and curated playlists...") # Still run these even when skipping main pool population - self.cache_discovery_recent_albums() - self.curate_discovery_playlists() + self.cache_discovery_recent_albums(profile_id=profile_id) + self.curate_discovery_playlists(profile_id=profile_id) return logger.info("Populating discovery pool from similar artists...") @@ -1546,15 +1550,15 @@ class WatchlistScanner: logger.info(f"Sources available - Spotify: {spotify_available}, iTunes: {itunes_available}") - # Get top similar artists across all watchlist (ordered by occurrence_count) - similar_artists = self.database.get_top_similar_artists(limit=top_artists_limit) + # Get top similar artists for this profile's watchlist (ordered by occurrence_count) + similar_artists = self.database.get_top_similar_artists(limit=top_artists_limit, profile_id=profile_id) if not similar_artists: logger.info("No similar artists found to populate discovery pool from similar artists") logger.info("But still caching recent albums from watchlist artists and curating playlists...") # Still run these even without similar artists - they use watchlist artists - self.cache_discovery_recent_albums() - self.curate_discovery_playlists() + self.cache_discovery_recent_albums(profile_id=profile_id) + self.curate_discovery_playlists(profile_id=profile_id) return logger.info(f"Processing {len(similar_artists)} top similar artists for discovery pool") @@ -1734,8 +1738,8 @@ class WatchlistScanner: track_data['itunes_album_id'] = album_data.get('id') track_data['itunes_artist_id'] = similar_artist.similar_artist_itunes_id - # Add to discovery pool with source - if self.database.add_to_discovery_pool(track_data, source=source): + # Add to discovery pool with source (scoped to profile) + if self.database.add_to_discovery_pool(track_data, source=source, profile_id=profile_id): total_tracks_added += 1 except Exception as track_error: @@ -1887,7 +1891,7 @@ class WatchlistScanner: track_data['itunes_album_id'] = album_data.get('id') track_data['itunes_artist_id'] = artist_id_for_genres or '' - if self.database.add_to_discovery_pool(track_data, source=db_source): + if self.database.add_to_discovery_pool(track_data, source=db_source, profile_id=profile_id): total_tracks_added += 1 except Exception as track_error: continue @@ -1918,23 +1922,23 @@ class WatchlistScanner: final_count = cursor.fetchone()['count'] # Update timestamp to mark when pool was last populated - self.database.update_discovery_pool_timestamp(track_count=final_count) + self.database.update_discovery_pool_timestamp(track_count=final_count, profile_id=profile_id) logger.info(f"Discovery pool now contains {final_count} total tracks (built over time)") # Cache recent albums for discovery page logger.info("Caching recent albums for discovery page...") - self.cache_discovery_recent_albums() + self.cache_discovery_recent_albums(profile_id=profile_id) # Curate playlists for consistent daily experience logger.info("Curating discovery playlists...") - self.curate_discovery_playlists() + self.curate_discovery_playlists(profile_id=profile_id) except Exception as e: logger.error(f"Error populating discovery pool: {e}") import traceback traceback.print_exc() - def update_discovery_pool_incremental(self): + def update_discovery_pool_incremental(self, profile_id: int = 1): """ Lightweight incremental update for discovery pool - runs every 6 hours. @@ -1948,13 +1952,13 @@ class WatchlistScanner: from datetime import datetime, timedelta # Check if we should run (prevents over-polling Spotify) - if not self.database.should_populate_discovery_pool(hours_threshold=6): + if not self.database.should_populate_discovery_pool(hours_threshold=6, profile_id=profile_id): logger.info("Discovery pool was updated recently (< 6 hours ago). Skipping incremental update.") return logger.info("Starting incremental discovery pool update (watchlist artists only)...") - watchlist_artists = self.database.get_watchlist_artists() + watchlist_artists = self.database.get_watchlist_artists(profile_id=profile_id) if not watchlist_artists: logger.info("No watchlist artists to check for incremental update") return @@ -2042,7 +2046,7 @@ class WatchlistScanner: 'artist_genres': artist_genres } - if self.database.add_to_discovery_pool(track_data): + if self.database.add_to_discovery_pool(track_data, profile_id=profile_id): total_tracks_added += 1 except Exception as track_error: @@ -2071,7 +2075,7 @@ class WatchlistScanner: cursor.execute("SELECT COUNT(*) as count FROM discovery_pool") current_count = cursor.fetchone()['count'] - self.database.update_discovery_pool_timestamp(track_count=current_count) + self.database.update_discovery_pool_timestamp(track_count=current_count, profile_id=profile_id) logger.info(f"Discovery pool now contains {current_count} total tracks") except Exception as e: @@ -2079,7 +2083,7 @@ class WatchlistScanner: import traceback traceback.print_exc() - def cache_discovery_recent_albums(self): + def cache_discovery_recent_albums(self, profile_id: int = 1): """ Cache recent albums from watchlist and similar artists for discover page. @@ -2091,8 +2095,8 @@ class WatchlistScanner: logger.info("Caching recent albums for discover page...") - # Clear existing cache - self.database.clear_discovery_recent_albums() + # Clear existing cache for this profile + self.database.clear_discovery_recent_albums(profile_id=profile_id) # 30-day window for recent releases cutoff_date = datetime.now() - timedelta(days=30) @@ -2106,9 +2110,9 @@ class WatchlistScanner: from core.itunes_client import iTunesClient itunes_client = iTunesClient() - # Get artists to check - watchlist_artists = self.database.get_watchlist_artists() - similar_artists = self.database.get_top_similar_artists(limit=50) + # Get artists to check (scoped to profile) + watchlist_artists = self.database.get_watchlist_artists(profile_id=profile_id) + similar_artists = self.database.get_top_similar_artists(limit=50, profile_id=profile_id) logger.info(f"Checking albums from {len(watchlist_artists)} watchlist + {len(similar_artists)} similar artists") logger.info(f"Sources: Spotify={spotify_available}, iTunes=True") @@ -2141,7 +2145,7 @@ class WatchlistScanner: 'release_date': release_str[:10], 'album_type': album.album_type if hasattr(album, 'album_type') else 'album' } - if self.database.cache_discovery_recent_album(album_data, source=source): + if self.database.cache_discovery_recent_album(album_data, source=source, profile_id=profile_id): cached_count[source] += 1 logger.debug(f"Cached [{source}] recent album: {album.name} by {artist_name} ({release_str})") return True @@ -2256,7 +2260,7 @@ class WatchlistScanner: import traceback traceback.print_exc() - def curate_discovery_playlists(self): + def curate_discovery_playlists(self, profile_id: int = 1): """ Curate consistent playlist selections that stay the same until next discovery pool update. @@ -2286,7 +2290,7 @@ class WatchlistScanner: logger.info(f"Curating Release Radar for {source}...") # 1. Curate Release Radar - 50 tracks from recent albums - recent_albums = self.database.get_discovery_recent_albums(limit=50, source=source) + recent_albums = self.database.get_discovery_recent_albums(limit=50, source=source, profile_id=profile_id) release_radar_tracks = [] if not recent_albums: @@ -2405,18 +2409,18 @@ class WatchlistScanner: formatted_track['itunes_track_id'] = track_data['id'] formatted_track['itunes_album_id'] = track_data['album'].get('id', '') - self.database.add_to_discovery_pool(formatted_track, source=source) + self.database.add_to_discovery_pool(formatted_track, source=source, profile_id=profile_id) except Exception as e: continue # Save with source suffix for multi-source support playlist_key = f'release_radar_{source}' - self.database.save_curated_playlist(playlist_key, release_radar_tracks) + self.database.save_curated_playlist(playlist_key, release_radar_tracks, profile_id=profile_id) logger.info(f"Release Radar ({source}) curated: {len(release_radar_tracks)} tracks") # 2. Curate Discovery Weekly - 50 tracks from discovery pool logger.info(f"Curating Discovery Weekly for {source}...") - discovery_tracks = self.database.get_discovery_pool_tracks(limit=2000, new_releases_only=False, source=source) + discovery_tracks = self.database.get_discovery_pool_tracks(limit=2000, new_releases_only=False, source=source, profile_id=profile_id) if not discovery_tracks: logger.warning(f"[{source.upper()}] No discovery pool tracks found for Discovery Weekly - check populate_discovery_pool()") @@ -2458,7 +2462,7 @@ class WatchlistScanner: discovery_weekly_tracks.append(track.itunes_track_id) playlist_key = f'discovery_weekly_{source}' - self.database.save_curated_playlist(playlist_key, discovery_weekly_tracks) + self.database.save_curated_playlist(playlist_key, discovery_weekly_tracks, profile_id=profile_id) logger.info(f"Discovery Weekly ({source}) curated: {len(discovery_weekly_tracks)} tracks") # Also save without suffix for backward compatibility (use active source) @@ -2467,10 +2471,10 @@ class WatchlistScanner: discovery_weekly_key = f'discovery_weekly_{active_source}' # Copy active source playlists to non-suffixed keys - release_radar_ids = self.database.get_curated_playlist(release_radar_key) or [] - discovery_weekly_ids = self.database.get_curated_playlist(discovery_weekly_key) or [] - self.database.save_curated_playlist('release_radar', release_radar_ids) - self.database.save_curated_playlist('discovery_weekly', discovery_weekly_ids) + release_radar_ids = self.database.get_curated_playlist(release_radar_key, profile_id=profile_id) or [] + discovery_weekly_ids = self.database.get_curated_playlist(discovery_weekly_key, profile_id=profile_id) or [] + self.database.save_curated_playlist('release_radar', release_radar_ids, profile_id=profile_id) + self.database.save_curated_playlist('discovery_weekly', discovery_weekly_ids, profile_id=profile_id) logger.info("Playlist curation complete") diff --git a/core/wishlist_service.py b/core/wishlist_service.py index 970f409a..a9acc4b8 100644 --- a/core/wishlist_service.py +++ b/core/wishlist_service.py @@ -25,8 +25,8 @@ class WishlistService: self._database = get_database(self.database_path) return self._database - def add_failed_track_from_modal(self, track_info: Dict[str, Any], source_type: str = "unknown", - source_context: Dict[str, Any] = None) -> bool: + def add_failed_track_from_modal(self, track_info: Dict[str, Any], source_type: str = "unknown", + source_context: Dict[str, Any] = None, profile_id: int = 1) -> bool: """ Add a failed track from a download modal to the wishlist. @@ -74,7 +74,8 @@ class WishlistService: spotify_track_data=spotify_track, failure_reason=failure_reason, source_type=source_type, - source_info=source_info + source_info=source_info, + profile_id=profile_id ) except Exception as e: @@ -82,30 +83,33 @@ class WishlistService: return False def add_spotify_track_to_wishlist(self, spotify_track_data: Dict[str, Any], failure_reason: str, - source_type: str = "manual", source_context: Dict[str, Any] = None) -> bool: + source_type: str = "manual", source_context: Dict[str, Any] = None, + profile_id: int = 1) -> bool: """ Directly add a Spotify track to the wishlist. - + Args: spotify_track_data: Full Spotify track data dictionary failure_reason: Reason for the failure source_type: Source type ('playlist', 'album', 'manual') source_context: Additional context information + profile_id: Profile to add to """ return self.database.add_to_wishlist( spotify_track_data=spotify_track_data, failure_reason=failure_reason, source_type=source_type, - source_info=source_context or {} + source_info=source_context or {}, + profile_id=profile_id ) - def get_wishlist_tracks_for_download(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: + def get_wishlist_tracks_for_download(self, limit: Optional[int] = None, profile_id: int = 1) -> List[Dict[str, Any]]: """ Get wishlist tracks formatted for the download modal. Returns tracks in a format similar to playlist tracks for compatibility. """ try: - wishlist_tracks = self.database.get_wishlist_tracks(limit=limit) + wishlist_tracks = self.database.get_wishlist_tracks(limit=limit, profile_id=profile_id) formatted_tracks = [] for wishlist_track in wishlist_tracks: @@ -144,28 +148,29 @@ class WishlistService: logger.error(f"Error getting wishlist tracks for download: {e}") return [] - def mark_track_download_result(self, spotify_track_id: str, success: bool, error_message: str = None) -> bool: + def mark_track_download_result(self, spotify_track_id: str, success: bool, error_message: str = None, profile_id: int = 1) -> bool: """ Mark the result of a download attempt for a wishlist track. - + Args: spotify_track_id: Spotify track ID success: Whether the download was successful error_message: Error message if failed + profile_id: Profile to scope the operation to """ - return self.database.update_wishlist_retry(spotify_track_id, success, error_message) + return self.database.update_wishlist_retry(spotify_track_id, success, error_message, profile_id=profile_id) - def remove_track_from_wishlist(self, spotify_track_id: str) -> bool: + def remove_track_from_wishlist(self, spotify_track_id: str, profile_id: int = 1) -> bool: """Remove a track from the wishlist (typically after successful download)""" - return self.database.remove_from_wishlist(spotify_track_id) - - def get_wishlist_count(self) -> int: + return self.database.remove_from_wishlist(spotify_track_id, profile_id=profile_id) + + def get_wishlist_count(self, profile_id: int = 1) -> int: """Get the total number of tracks in the wishlist""" - return self.database.get_wishlist_count() - - def clear_wishlist(self) -> bool: + return self.database.get_wishlist_count(profile_id=profile_id) + + def clear_wishlist(self, profile_id: int = 1) -> bool: """Clear all tracks from the wishlist""" - return self.database.clear_wishlist() + return self.database.clear_wishlist(profile_id=profile_id) def check_track_in_wishlist(self, spotify_track_id: str) -> bool: """Check if a track exists in the wishlist by Spotify track ID""" @@ -213,20 +218,20 @@ class WishlistService: logger.error(f"Error finding matching wishlist track: {e}") return None - def get_wishlist_summary(self) -> Dict[str, Any]: + def get_wishlist_summary(self, profile_id: int = 1) -> Dict[str, Any]: """Get a summary of the wishlist for dashboard display""" try: - total_tracks = self.get_wishlist_count() - + total_tracks = self.get_wishlist_count(profile_id=profile_id) + if total_tracks == 0: return { 'total_tracks': 0, 'by_source_type': {}, 'recent_failures': [] } - + # Get detailed breakdown - wishlist_tracks = self.database.get_wishlist_tracks() + wishlist_tracks = self.database.get_wishlist_tracks(profile_id=profile_id) # Group by source type by_source_type = {} diff --git a/database/music_database.py b/database/music_database.py index d6e8d30d..3b07dfe9 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -94,6 +94,7 @@ class WatchlistArtist: include_remixes: bool = False include_acoustic: bool = False include_compilations: bool = False + profile_id: int = 1 @dataclass class SimilarArtist: @@ -331,6 +332,12 @@ class MusicDatabase: # 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) + conn.commit() logger.info("Database initialized successfully") @@ -1333,6 +1340,618 @@ class MusicDatabase: 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") + if cursor.fetchone(): + return # Already migrated + + 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. Add profile_id column to per-profile tables + 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"Added profile_id column to {table}") + except Exception as e: + logger.warning(f"Could not add profile_id to {table}: {e}") + + # 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] + + 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, + itunes_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', + 'itunes_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(""" + 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(""" + 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(""" + 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: + pass + + # 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(""" + 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, + 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', + '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(""" + CREATE TABLE discovery_recent_albums_new ( + 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, + profile_id INTEGER DEFAULT 1, + UNIQUE(profile_id, album_spotify_id, album_itunes_id, source) + ) + """) + + new_cols = ['id', 'album_spotify_id', 'album_itunes_id', 'artist_spotify_id', + 'artist_itunes_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(""" + CREATE TABLE recent_releases_new ( + 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, + 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', + '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") + if cursor.fetchone(): + return # Already migrated + + 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]: + cursor.execute("PRAGMA table_info(similar_artists)") + old_cols = [c[1] for c in cursor.fetchall()] + + 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_name TEXT NOT NULL, + similarity_rank INTEGER DEFAULT 1, + occurrence_count INTEGER DEFAULT 1, + last_updated TIMESTAMP DEFAULT CURRENT_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_name', + 'similarity_rank', 'occurrence_count', 'last_updated', + '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(""" + 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}") + + # ── 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] + 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, + 'created_at': row['created_at'], + 'updated_at': row['updated_at'], + } for row in rows] + 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] + 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, + '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) -> Optional[int]: + """Create a new profile. Returns new profile ID or None on error.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO profiles (name, avatar_color, pin_hash, is_admin, avatar_url) + VALUES (?, ?, ?, ?, ?) + """, (name, avatar_color, pin_hash, int(is_admin), avatar_url)) + 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.""" + allowed = {'name', 'avatar_color', 'avatar_url', 'pin_hash', 'is_admin'} + 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()) + 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(f"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: + pass + 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 @@ -3335,29 +3954,42 @@ class MusicDatabase: # --- Bubble Snapshot Methods --- - def save_bubble_snapshot(self, snapshot_type: str, data_dict: dict): - """Save a bubble snapshot (upserts by type). + 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() - 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')) - ) + # 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) -> Optional[Dict[str, Any]]: - """Load a bubble snapshot. + 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 @@ -3365,7 +3997,13 @@ class MusicDatabase: try: with self._get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT data, timestamp FROM bubble_snapshots WHERE type = ?", (snapshot_type,)) + 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']} @@ -3374,12 +4012,18 @@ class MusicDatabase: logger.error(f"Error getting bubble snapshot '{snapshot_type}': {e}") return None - def delete_bubble_snapshot(self, snapshot_type: str): - """Delete a bubble snapshot.""" + 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("DELETE FROM bubble_snapshots WHERE type = ?", (snapshot_type,)) + 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}") @@ -3559,7 +4203,8 @@ class MusicDatabase: # Wishlist management methods def add_to_wishlist(self, spotify_track_data: Dict[str, Any], failure_reason: str = "Download failed", - source_type: str = "unknown", source_info: Dict[str, Any] = None) -> bool: + source_type: str = "unknown", source_info: Dict[str, Any] = None, + profile_id: int = 1) -> bool: """Add a failed track to the wishlist for retry""" try: with self._get_connection() as conn: @@ -3588,7 +4233,8 @@ class MusicDatabase: # This prevents adding the same track multiple times with different IDs or edge cases cursor.execute(""" SELECT id, spotify_track_id, spotify_data FROM wishlist_tracks - """) + WHERE profile_id = ? + """, (profile_id,)) existing_tracks = cursor.fetchall() @@ -3625,9 +4271,9 @@ class MusicDatabase: # No duplicate found, insert the track cursor.execute(""" INSERT OR REPLACE INTO wishlist_tracks - (spotify_track_id, spotify_data, failure_reason, source_type, source_info, date_added) - VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - """, (track_id, spotify_json, failure_reason, source_type, source_json)) + (spotify_track_id, spotify_data, failure_reason, source_type, source_info, date_added, profile_id) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) + """, (track_id, spotify_json, failure_reason, source_type, source_json, profile_id)) conn.commit() @@ -3638,42 +4284,45 @@ class MusicDatabase: logger.error(f"Error adding track to wishlist: {e}") return False - def remove_from_wishlist(self, spotify_track_id: str) -> bool: + 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 = ?", (spotify_track_id,)) + 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) -> List[Dict[str, Any]]: - """Get all tracks in the wishlist, ordered by date added (oldest first for retry priority)""" + def get_wishlist_tracks(self, limit: Optional[int] = None, profile_id: int = 1) -> List[Dict[str, Any]]: + """Get all tracks in the wishlist for the given profile, ordered by date added (oldest first for retry priority)""" try: with self._get_connection() as conn: cursor = conn.cursor() - + query = """ - SELECT id, spotify_track_id, spotify_data, failure_reason, retry_count, + SELECT id, spotify_track_id, spotify_data, failure_reason, retry_count, last_attempted, date_added, source_type, source_info - FROM wishlist_tracks + FROM wishlist_tracks + WHERE profile_id = ? ORDER BY date_added """ - + + params = [profile_id] if limit: query += f" LIMIT {limit}" - - cursor.execute(query) + + cursor.execute(query, params) rows = cursor.fetchall() wishlist_tracks = [] @@ -3703,24 +4352,24 @@ class MusicDatabase: logger.error(f"Error getting wishlist tracks: {e}") return [] - def update_wishlist_retry(self, spotify_track_id: str, success: bool, error_message: str = None) -> bool: + 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 wishlist on 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, + UPDATE wishlist_tracks + SET retry_count = retry_count + 1, last_attempted = CURRENT_TIMESTAMP, failure_reason = COALESCE(?, failure_reason) - WHERE spotify_track_id = ? - """, (error_message, spotify_track_id)) + WHERE spotify_track_id = ? AND profile_id = ? + """, (error_message, spotify_track_id, profile_id)) conn.commit() return cursor.rowcount > 0 @@ -3729,38 +4378,38 @@ class MusicDatabase: logger.error(f"Error updating wishlist retry status: {e}") return False - def get_wishlist_count(self) -> int: - """Get the total number of tracks in the wishlist""" + def get_wishlist_count(self, profile_id: int = 1) -> int: + """Get the total number of tracks in the wishlist for the given profile""" try: with self._get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT COUNT(*) FROM wishlist_tracks") + cursor.execute("SELECT COUNT(*) FROM wishlist_tracks WHERE profile_id = ?", (profile_id,)) 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) -> bool: - """Clear all tracks from the wishlist and reset scan timestamps so next scan re-discovers everything""" + def clear_wishlist(self, profile_id: int = 1) -> bool: + """Clear all tracks from the wishlist for the given profile and reset scan timestamps""" try: with self._get_connection() as conn: cursor = conn.cursor() - cursor.execute("DELETE FROM wishlist_tracks") + cursor.execute("DELETE FROM wishlist_tracks WHERE profile_id = ?", (profile_id,)) cleared_count = cursor.rowcount - # Reset last_scan_timestamp on all watchlist artists so the next scan + # Reset last_scan_timestamp on this profile's watchlist artists so the next scan # uses the lookback period setting (e.g. "entire discography") instead # of only finding albums released after the old scan date - cursor.execute("UPDATE watchlist_artists SET last_scan_timestamp = NULL") + cursor.execute("UPDATE watchlist_artists SET last_scan_timestamp = NULL WHERE profile_id = ?", (profile_id,)) reset_count = cursor.rowcount conn.commit() - logger.info(f"Cleared {cleared_count} tracks from wishlist, reset scan timestamps on {reset_count} artists") + logger.info(f"Cleared {cleared_count} tracks from wishlist, reset scan timestamps on {reset_count} artists (profile: {profile_id})") return True except Exception as e: logger.error(f"Error clearing wishlist: {e}") return False - def remove_wishlist_duplicates(self) -> int: + def remove_wishlist_duplicates(self, profile_id: int = 1) -> int: """Remove duplicate tracks from wishlist based on track name + artist. Keeps the oldest entry (by date_added) for each duplicate set. Returns the number of duplicates removed.""" @@ -3768,12 +4417,13 @@ class MusicDatabase: with self._get_connection() as conn: cursor = conn.cursor() - # Get all wishlist tracks + # 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 @@ -3821,7 +4471,7 @@ class MusicDatabase: return 0 # Watchlist operations - def add_artist_to_watchlist(self, artist_id: str, artist_name: str) -> bool: + def add_artist_to_watchlist(self, artist_id: str, artist_name: str, profile_id: int = 1) -> bool: """Add an artist to the watchlist for monitoring new releases. Automatically detects if artist_id is a Spotify ID (alphanumeric) or iTunes ID (numeric). @@ -3836,17 +4486,17 @@ class MusicDatabase: if is_itunes_id: cursor.execute(""" INSERT OR REPLACE INTO watchlist_artists - (itunes_artist_id, artist_name, date_added, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """, (artist_id, artist_name)) - logger.info(f"Added artist '{artist_name}' to watchlist (iTunes ID: {artist_id})") + (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})") else: cursor.execute(""" INSERT OR REPLACE INTO watchlist_artists - (spotify_artist_id, artist_name, date_added, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """, (artist_id, artist_name)) - logger.info(f"Added artist '{artist_name}' to watchlist (Spotify ID: {artist_id})") + (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 @@ -3855,7 +4505,7 @@ class MusicDatabase: logger.error(f"Error adding artist '{artist_name}' to watchlist: {e}") return False - def remove_artist_from_watchlist(self, artist_id: str) -> bool: + def remove_artist_from_watchlist(self, artist_id: str, profile_id: int = 1) -> bool: """Remove an artist from the watchlist (checks both Spotify and iTunes IDs)""" try: with self._get_connection() as conn: @@ -3864,29 +4514,29 @@ class MusicDatabase: # Get artist name for logging (check both ID columns) cursor.execute(""" SELECT artist_name FROM watchlist_artists - WHERE spotify_artist_id = ? OR itunes_artist_id = ? - """, (artist_id, artist_id)) + WHERE (spotify_artist_id = ? OR itunes_artist_id = ?) AND profile_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 = ? - """, (artist_id, artist_id)) + WHERE (spotify_artist_id = ? OR itunes_artist_id = ?) AND profile_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})") + 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") + 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) -> bool: + def is_artist_in_watchlist(self, artist_id: str, profile_id: int = 1) -> bool: """Check if an artist is currently in the watchlist (checks both Spotify and iTunes IDs)""" try: with self._get_connection() as conn: @@ -3895,9 +4545,9 @@ class MusicDatabase: # Check both spotify_artist_id and itunes_artist_id columns cursor.execute(""" SELECT 1 FROM watchlist_artists - WHERE spotify_artist_id = ? OR itunes_artist_id = ? + WHERE (spotify_artist_id = ? OR itunes_artist_id = ?) AND profile_id = ? LIMIT 1 - """, (artist_id, artist_id)) + """, (artist_id, artist_id, profile_id)) result = cursor.fetchone() return result is not None @@ -3906,8 +4556,8 @@ class MusicDatabase: logger.error(f"Error checking if artist is in watchlist (ID: {artist_id}): {e}") return False - def get_watchlist_artists(self) -> List[WatchlistArtist]: - """Get all artists in the watchlist""" + 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() @@ -3924,11 +4574,19 @@ class MusicDatabase: columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns] - cursor.execute(f""" - SELECT {', '.join(columns_to_select)} - FROM watchlist_artists - ORDER BY date_added DESC - """) + 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() @@ -3961,7 +4619,8 @@ class MusicDatabase: include_live=include_live, include_remixes=include_remixes, include_acoustic=include_acoustic, - include_compilations=include_compilations + include_compilations=include_compilations, + profile_id=profile_id )) return watchlist_artists @@ -3970,17 +4629,17 @@ class MusicDatabase: logger.error(f"Error getting watchlist artists: {e}") return [] - def get_watchlist_count(self) -> int: - """Get the number of artists in the watchlist""" + 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") + + 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 @@ -4078,7 +4737,8 @@ class MusicDatabase: 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) -> bool: + similarity_rank: int = 1, + profile_id: int = 1) -> bool: """Add or update a similar artist recommendation (supports both Spotify and iTunes IDs)""" try: with self._get_connection() as conn: @@ -4087,16 +4747,16 @@ class MusicDatabase: # Use artist name as the unique key (allows storing both IDs for same artist) cursor.execute(""" INSERT INTO similar_artists - (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name, similarity_rank, occurrence_count, last_updated) - VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP) - ON CONFLICT(source_artist_id, similar_artist_name) + (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name, similarity_rank, occurrence_count, last_updated, profile_id) + VALUES (?, ?, ?, ?, ?, 1, 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), similarity_rank = excluded.similarity_rank, occurrence_count = occurrence_count + 1, last_updated = CURRENT_TIMESTAMP - """, (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name, similarity_rank)) + """, (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name, similarity_rank, profile_id)) conn.commit() return True @@ -4105,7 +4765,7 @@ class MusicDatabase: logger.error(f"Error adding similar artist: {e}") return False - def get_similar_artists_for_source(self, source_artist_id: str) -> List[SimilarArtist]: + 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: @@ -4113,9 +4773,9 @@ class MusicDatabase: cursor.execute(""" SELECT * FROM similar_artists - WHERE source_artist_id = ? + WHERE source_artist_id = ? AND profile_id = ? ORDER BY similarity_rank ASC - """, (source_artist_id,)) + """, (source_artist_id, profile_id)) rows = cursor.fetchall() return [SimilarArtist( @@ -4133,7 +4793,7 @@ class MusicDatabase: logger.error(f"Error getting similar artists: {e}") return [] - def get_similar_artists_missing_itunes_ids(self, source_artist_id: str) -> List[SimilarArtist]: + def get_similar_artists_missing_itunes_ids(self, source_artist_id: str, profile_id: int = 1) -> List[SimilarArtist]: """Get similar artists for a source that are missing iTunes IDs (for backfill)""" try: with self._get_connection() as conn: @@ -4141,11 +4801,11 @@ class MusicDatabase: cursor.execute(""" SELECT * FROM similar_artists - WHERE source_artist_id = ? + WHERE source_artist_id = ? AND profile_id = ? AND (similar_artist_itunes_id IS NULL OR similar_artist_itunes_id = '') ORDER BY occurrence_count DESC LIMIT 50 - """, (source_artist_id,)) + """, (source_artist_id, profile_id)) rows = cursor.fetchall() return [SimilarArtist( @@ -4182,7 +4842,7 @@ class MusicDatabase: logger.error(f"Error updating similar artist iTunes ID: {e}") return False - def has_fresh_similar_artists(self, source_artist_id: str, days_threshold: int = 30, require_itunes: bool = True, require_spotify: bool = False) -> bool: + def has_fresh_similar_artists(self, source_artist_id: str, days_threshold: int = 30, require_itunes: bool = True, require_spotify: bool = False, profile_id: int = 1) -> bool: """ Check if we have cached similar artists that are still fresh ( 0: @@ -4241,8 +4902,8 @@ class MusicDatabase: SELECT COUNT(*) as total, SUM(CASE WHEN similar_artist_spotify_id IS NOT NULL AND similar_artist_spotify_id != '' THEN 1 ELSE 0 END) as has_spotify FROM similar_artists - WHERE source_artist_id = ? - """, (source_artist_id,)) + WHERE source_artist_id = ? AND profile_id = ? + """, (source_artist_id, profile_id)) id_row = cursor.fetchone() if id_row and id_row['total'] > 0: @@ -4258,7 +4919,7 @@ class MusicDatabase: 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) -> List[SimilarArtist]: + def get_top_similar_artists(self, limit: int = 50, profile_id: int = 1) -> List[SimilarArtist]: """Get top similar artists excluding watchlist artists, with cycling support""" try: with self._get_connection() as conn: @@ -4279,8 +4940,8 @@ class MusicDatabase: (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 LOWER(sa.similar_artist_name) = LOWER(wa.artist_name) - ) - WHERE wa.id IS NULL + ) AND wa.profile_id = ? + WHERE wa.id IS NULL AND sa.profile_id = ? GROUP BY sa.similar_artist_name ORDER BY CASE WHEN MAX(sa.last_featured) IS NULL THEN 0 ELSE 1 END, @@ -4288,7 +4949,7 @@ class MusicDatabase: occurrence_count DESC, similarity_rank ASC LIMIT ? - """, (limit,)) + """, (profile_id, profile_id, limit)) rows = cursor.fetchall() return [SimilarArtist( @@ -4323,23 +4984,23 @@ class MusicDatabase: 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') -> bool: + 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 both Spotify and iTunes sources)""" try: with self._get_connection() as conn: cursor = conn.cursor() - # Check if track already exists based on source + # 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'", - (track_data['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'", - (track_data['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)) 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 = ?", - (track_data['track_name'], track_data['artist_name'], source)) + 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 @@ -4353,8 +5014,8 @@ class MusicDatabase: (spotify_track_id, spotify_album_id, spotify_artist_id, itunes_track_id, itunes_album_id, itunes_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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + 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'), @@ -4372,7 +5033,8 @@ class MusicDatabase: track_data['release_date'], track_data.get('is_new_release', False), json.dumps(track_data['track_data_json']), - artist_genres_json + artist_genres_json, + profile_id )) conn.commit() @@ -4382,26 +5044,27 @@ class MusicDatabase: logger.error(f"Error adding to discovery pool: {e}") return False - def rotate_discovery_pool(self, max_tracks: int = 2000, remove_count: int = 500): + 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 - cursor.execute("SELECT COUNT(*) as count FROM discovery_pool") + # 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 + # 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 ? ) - """, (remove_count,)) + """, (profile_id, remove_count)) conn.commit() logger.info(f"Removed {remove_count} oldest tracks from discovery pool") @@ -4409,15 +5072,15 @@ class MusicDatabase: 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) -> List[DiscoveryTrack]: + 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' or 'itunes')""" try: with self._get_connection() as conn: cursor = conn.cursor() # Build query with optional source filter - where_clauses = [] - params = [] + where_clauses = ["profile_id = ?"] + params = [profile_id] if new_releases_only: where_clauses.append("is_new_release = 1") @@ -4464,7 +5127,7 @@ class MusicDatabase: logger.error(f"Error getting discovery pool tracks: {e}") return [] - def cache_discovery_recent_album(self, album_data: Dict[str, Any], source: str = 'spotify') -> bool: + 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 both Spotify and iTunes sources)""" try: with self._get_connection() as conn: @@ -4473,8 +5136,8 @@ class MusicDatabase: cursor.execute(""" INSERT OR REPLACE INTO discovery_recent_albums (album_spotify_id, album_itunes_id, artist_spotify_id, artist_itunes_id, source, - album_name, artist_name, album_cover_url, release_date, album_type, cached_date) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + 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'), @@ -4485,7 +5148,8 @@ class MusicDatabase: album_data['artist_name'], album_data.get('album_cover_url'), album_data['release_date'], - album_data.get('album_type', 'album') + album_data.get('album_type', 'album'), + profile_id )) conn.commit() @@ -4495,7 +5159,7 @@ class MusicDatabase: logger.error(f"Error caching discovery recent album: {e}") return False - def get_discovery_recent_albums(self, limit: int = 10, source: Optional[str] = None) -> List[Dict[str, Any]]: + 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: @@ -4504,16 +5168,17 @@ class MusicDatabase: if source: cursor.execute(""" SELECT * FROM discovery_recent_albums - WHERE source = ? + WHERE source = ? AND profile_id = ? ORDER BY release_date DESC LIMIT ? - """, (source, limit)) + """, (source, profile_id, limit)) else: cursor.execute(""" SELECT * FROM discovery_recent_albums + WHERE profile_id = ? ORDER BY release_date DESC LIMIT ? - """, (limit,)) + """, (profile_id, limit)) rows = cursor.fetchall() row_keys = rows[0].keys() if rows else [] @@ -4535,45 +5200,48 @@ class MusicDatabase: logger.error(f"Error getting discovery recent albums: {e}") return [] - def clear_discovery_recent_albums(self) -> bool: - """Clear all cached recent albums""" + 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") + 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]) -> bool: + 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 OR REPLACE INTO discovery_curated_playlists - (playlist_type, track_ids_json, curated_date) - VALUES (?, ?, CURRENT_TIMESTAMP) - """, (playlist_type, json.dumps(track_ids))) + 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) -> Optional[List[str]]: - """Get saved curated playlist track IDs""" + 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 = ? - """, (playlist_type,)) + WHERE playlist_type = ? AND profile_id = ? + """, (playlist_type, profile_id)) row = cursor.fetchone() if row: return json.loads(row['track_ids_json']) @@ -4582,7 +5250,7 @@ class MusicDatabase: logger.error(f"Error getting curated playlist {playlist_type}: {e}") return None - def should_populate_discovery_pool(self, hours_threshold: int = 24) -> bool: + 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: @@ -4590,8 +5258,8 @@ class MusicDatabase: cursor.execute(""" SELECT last_populated_timestamp FROM discovery_pool_metadata - WHERE id = 1 - """) + WHERE profile_id = ? + """, (profile_id,)) row = cursor.fetchone() if not row: @@ -4607,16 +5275,20 @@ class MusicDatabase: 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) -> bool: + 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 OR REPLACE INTO discovery_pool_metadata - (id, last_populated_timestamp, track_count, updated_at) - VALUES (1, ?, ?, CURRENT_TIMESTAMP) - """, (datetime.now().isoformat(), track_count)) + 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: @@ -4647,7 +5319,7 @@ class MusicDatabase: 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]) -> bool: + 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: @@ -4655,15 +5327,16 @@ class MusicDatabase: 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) - VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + (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) + album_data.get('track_count', 0), + profile_id )) conn.commit() @@ -4673,17 +5346,18 @@ class MusicDatabase: logger.error(f"Error adding recent release: {e}") return False - def get_recent_releases(self, limit: int = 50) -> List[RecentRelease]: - """Get recent releases from watchlist artists""" + 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 ? - """, (limit,)) + """, (profile_id, limit)) rows = cursor.fetchall() return [RecentRelease( @@ -4810,7 +5484,7 @@ class MusicDatabase: 'server_source': server_source } - def get_library_artists(self, search_query: str = "", letter: str = "", page: int = 1, limit: int = 50, watchlist_filter: str = "all") -> Dict[str, Any]: + def get_library_artists(self, search_query: str = "", letter: str = "", page: int = 1, limit: int = 50, watchlist_filter: str = "all", profile_id: int = 1) -> Dict[str, Any]: """ Get artists for the library page with search, filtering, and pagination @@ -4855,8 +5529,8 @@ class MusicDatabase: where_clause = " AND ".join(where_conditions) if where_conditions else "1=1" - # Pre-fetch watchlist data (small table, single fast query) - cursor.execute("SELECT spotify_artist_id, itunes_artist_id, LOWER(artist_name) as name_lower FROM watchlist_artists") + # 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']} diff --git a/web_server.py b/web_server.py index 0cb5fe08..94235b56 100644 --- a/web_server.py +++ b/web_server.py @@ -17,7 +17,7 @@ from pathlib import Path from urllib.parse import urljoin from concurrent.futures import ThreadPoolExecutor, as_completed -from flask import Flask, render_template, request, jsonify, redirect, send_file, Response +from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g from flask_socketio import SocketIO, emit, join_room, leave_room from utils.logging_config import get_logger from utils.async_helpers import run_async @@ -120,9 +120,61 @@ app = Flask( static_folder=os.path.join(base_dir, 'webui', 'static') ) +# --- Flask Session Setup (for multi-profile support) --- +import secrets as _secrets +def _init_flask_secret_key(): + """Load or generate a persistent secret key for Flask sessions""" + try: + db = get_database() + key = db.get_metadata('flask_secret_key') + if not key: + key = _secrets.token_hex(32) + db.set_metadata('flask_secret_key', key) + return key + except Exception: + return _secrets.token_hex(32) + +app.secret_key = _init_flask_secret_key() + # --- WebSocket (Socket.IO) Setup --- socketio = SocketIO(app, async_mode='threading', cors_allowed_origins='*') +# --- Profile Context (before_request hook) --- +@app.before_request +def _set_profile_context(): + """Set g.profile_id from session for every request""" + # Skip for profile management, static, and root routes + path = request.path + if (path.startswith('/api/profiles') or + path.startswith('/static/') or + path == '/' or + path.startswith('/api/v1/')): + g.profile_id = session.get('profile_id', 1) + return + + pid = session.get('profile_id', 1) + + # Validate session profile still exists (handles deleted profiles) + if pid != 1 and 'profile_id' in session: + try: + database = get_database() + profile = database.get_profile(pid) + if not profile: + session.pop('profile_id', None) + from flask import jsonify as _jsonify + return _jsonify({"error": "profile_required", "message": "Profile no longer exists"}), 401 + except Exception: + pass # DB error — don't block requests, use the session value + + g.profile_id = pid + +def get_current_profile_id() -> int: + """Get the current profile ID from Flask g context or default to 1""" + try: + return g.profile_id + except AttributeError: + return 1 + # --- Docker Helper Functions --- def docker_resolve_path(path_str): """ @@ -5775,7 +5827,8 @@ def get_library_artists(): letter=letter, page=page, limit=limit, - watchlist_filter=watchlist_filter + watchlist_filter=watchlist_filter, + profile_id=get_current_profile_id() ) # Fix image URLs for all artists @@ -11654,24 +11707,33 @@ def _check_and_remove_from_wishlist(context): wishlist_id = track_info['wishlist_id'] print(f"📋 [Wishlist] Found wishlist_id in context: {wishlist_id}") - # Get the Spotify track ID from the wishlist entry - wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download() + # Get the Spotify track ID from the wishlist entry (search all profiles) + database = get_database() + all_profiles = database.get_all_profiles() + wishlist_tracks = [] + for p in all_profiles: + wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=p['id'])) for wl_track in wishlist_tracks: if wl_track.get('wishlist_id') == wishlist_id: spotify_track_id = wl_track.get('spotify_track_id') or wl_track.get('id') print(f"📋 [Wishlist] Found Spotify ID from wishlist entry: {spotify_track_id}") break - + # Method 4: Try to construct ID from track metadata for fuzzy matching if not spotify_track_id: track_name = track_info.get('name') or context.get('original_search_result', {}).get('title', '') artist_name = _get_track_artist_name(track_info) or _get_track_artist_name(context.get('original_search_result', {})) - + if track_name and artist_name: print(f"📋 [Wishlist] No Spotify ID found, checking for fuzzy match: '{track_name}' by '{artist_name}'") - - # Get all wishlist tracks and find potential matches - wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download() + + # Get all wishlist tracks and find potential matches (search all profiles) + if not wishlist_tracks: + database = get_database() + all_profiles = database.get_all_profiles() + wishlist_tracks = [] + for p in all_profiles: + wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=p['id'])) for wl_track in wishlist_tracks: wl_name = wl_track.get('name', '').lower() wl_artists = wl_track.get('artists', []) @@ -11741,9 +11803,13 @@ def _check_and_remove_track_from_wishlist_by_metadata(track_data): primary_artist = str(artists[0]) print(f"📋 [Analysis] No direct ID match, trying fuzzy match: '{track_name}' by '{primary_artist}'") - - # Get all wishlist tracks and find matches - wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download() + + # Get all wishlist tracks and find matches (search all profiles) + database = get_database() + all_profiles = database.get_all_profiles() + wishlist_tracks = [] + for p in all_profiles: + wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=p['id'])) for wl_track in wishlist_tracks: wl_name = wl_track.get('name', '').lower() wl_artists = wl_track.get('artists', []) @@ -11788,9 +11854,13 @@ def _automatic_wishlist_cleanup_after_db_update(): active_server = config_manager.get_active_media_server() print("📋 [Auto Cleanup] Starting automatic wishlist cleanup after database update...") - - # Get all wishlist tracks - wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download() + + # Get all wishlist tracks (across all profiles - cleanup is global) + database = get_database() + all_profiles = database.get_all_profiles() + wishlist_tracks = [] + for p in all_profiles: + wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=p['id'])) if not wishlist_tracks: print("📋 [Auto Cleanup] No tracks in wishlist to clean up") return @@ -12391,10 +12461,12 @@ def _process_wishlist_automatically(): with app.app_context(): from core.wishlist_service import get_wishlist_service wishlist_service = get_wishlist_service() - - # Check if wishlist has tracks - count = wishlist_service.get_wishlist_count() - print(f"🔍 [Auto-Wishlist] Wishlist count check: {count} tracks found") + + # Check if wishlist has tracks across all profiles + database = get_database() + all_profiles = database.get_all_profiles() + count = sum(wishlist_service.get_wishlist_count(profile_id=p['id']) for p in all_profiles) + print(f"🔍 [Auto-Wishlist] Wishlist count check: {count} tracks found across {len(all_profiles)} profiles") if count == 0: print("ℹ️ [Auto-Wishlist] No tracks in wishlist for auto-processing.") with wishlist_timer_lock: @@ -12426,14 +12498,17 @@ def _process_wishlist_automatically(): db = MusicDatabase() print("🧹 [Auto-Wishlist] Cleaning duplicate tracks before processing...") - duplicates_removed = db.remove_wishlist_duplicates() - if duplicates_removed > 0: - print(f"🧹 [Auto-Wishlist] Removed {duplicates_removed} duplicate tracks") + for p in all_profiles: + duplicates_removed = db.remove_wishlist_duplicates(profile_id=p['id']) + if duplicates_removed > 0: + print(f"🧹 [Auto-Wishlist] Removed {duplicates_removed} duplicate tracks from profile {p['id']}") # CLEANUP: Remove tracks from wishlist that already exist in library # This prevents wasting bandwidth on tracks we already have print("🧼 [Auto-Wishlist] Checking wishlist against library for already-owned tracks...") - cleanup_tracks = wishlist_service.get_wishlist_tracks_for_download() + cleanup_tracks = [] + for p in all_profiles: + cleanup_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=p['id'])) cleanup_removed = 0 for track in cleanup_tracks: @@ -12480,8 +12555,10 @@ def _process_wishlist_automatically(): if cleanup_removed > 0: print(f"✅ [Auto-Wishlist] Cleaned up {cleanup_removed} already-owned tracks from wishlist") - # Get wishlist tracks for processing (after cleanup) - raw_wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download() + # Get wishlist tracks for processing (after cleanup) - combine all profiles + raw_wishlist_tracks = [] + for p in all_profiles: + raw_wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=p['id'])) if not raw_wishlist_tracks: print("⚠️ No tracks returned from wishlist service.") with wishlist_timer_lock: @@ -12600,7 +12677,9 @@ def _process_wishlist_automatically(): 'auto_initiated': True, 'auto_processing_timestamp': time.time(), # Store current cycle for toggling after completion - 'current_cycle': current_cycle + 'current_cycle': current_cycle, + # Profile context for failed track wishlist re-adds (auto = profile 1 default) + 'profile_id': 1 } print(f"🚀 Starting automatic wishlist batch {batch_id} with {len(wishlist_tracks)} tracks") @@ -12744,7 +12823,7 @@ def get_wishlist_count(): try: from core.wishlist_service import get_wishlist_service wishlist_service = get_wishlist_service() - count = wishlist_service.get_wishlist_count() + count = wishlist_service.get_wishlist_count(profile_id=get_current_profile_id()) return jsonify({"count": count}) except Exception as e: print(f"Error getting wishlist count: {e}") @@ -12766,7 +12845,7 @@ def get_wishlist_stats(): from core.wishlist_service import get_wishlist_service wishlist_service = get_wishlist_service() - raw_tracks = wishlist_service.get_wishlist_tracks_for_download() + raw_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=get_current_profile_id()) singles_count = 0 albums_count = 0 @@ -12997,14 +13076,14 @@ def get_wishlist_tracks(): if not wishlist_batch_active: db = MusicDatabase() - duplicates_removed = db.remove_wishlist_duplicates() + duplicates_removed = db.remove_wishlist_duplicates(profile_id=get_current_profile_id()) if duplicates_removed > 0: print(f"🧹 Cleaned {duplicates_removed} duplicate tracks from wishlist") else: print(f"⏸️ Skipping wishlist duplicate cleanup - download in progress") wishlist_service = get_wishlist_service() - raw_tracks = wishlist_service.get_wishlist_tracks_for_download() + raw_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=get_current_profile_id()) # SANITIZE: Ensure consistent data format for frontend sanitized_tracks = [] @@ -13088,14 +13167,15 @@ def start_wishlist_missing_downloads(): # This prevents the "11 tracks shown but 12 counted" bug print("🧹 [Manual-Wishlist] Cleaning duplicate tracks before download...") db = MusicDatabase() - duplicates_removed = db.remove_wishlist_duplicates() + manual_profile_id = get_current_profile_id() + duplicates_removed = db.remove_wishlist_duplicates(profile_id=manual_profile_id) if duplicates_removed > 0: print(f"🧹 [Manual-Wishlist] Removed {duplicates_removed} duplicate tracks") # CLEANUP: Remove tracks from wishlist that already exist in library # This prevents wasting bandwidth on tracks we already have print("🧼 [Manual-Wishlist] Checking wishlist against library for already-owned tracks...") - cleanup_tracks = wishlist_service.get_wishlist_tracks_for_download() + cleanup_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=manual_profile_id) cleanup_removed = 0 for track in cleanup_tracks: @@ -13143,7 +13223,7 @@ def start_wishlist_missing_downloads(): print(f"✅ [Manual-Wishlist] Cleaned up {cleanup_removed} already-owned tracks from wishlist") # Get wishlist tracks formatted for download modal (after cleanup) - raw_wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download() + raw_wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=manual_profile_id) if not raw_wishlist_tracks: return jsonify({"success": False, "error": "No tracks in wishlist"}), 400 @@ -13265,7 +13345,9 @@ def start_wishlist_missing_downloads(): 'permanently_failed_tracks': [], 'cancelled_tracks': set(), # Wishlist tracks are already known-missing — always skip the library check - 'force_download_all': True + 'force_download_all': True, + # Profile context for failed track wishlist re-adds + 'profile_id': manual_profile_id } # Submit the wishlist processing job using the same processing function @@ -13286,8 +13368,8 @@ def clear_wishlist(): try: from core.wishlist_service import get_wishlist_service wishlist_service = get_wishlist_service() - success = wishlist_service.clear_wishlist() - + success = wishlist_service.clear_wishlist(profile_id=get_current_profile_id()) + if success: return jsonify({"success": True, "message": "Wishlist cleared successfully"}) else: @@ -13309,12 +13391,13 @@ def cleanup_wishlist(): active_server = config_manager.get_active_media_server() print("📋 [Wishlist Cleanup] Starting wishlist cleanup process...") - - # Get all wishlist tracks - wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download() + + # Get wishlist tracks for current profile + cleanup_profile_id = get_current_profile_id() + wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=cleanup_profile_id) if not wishlist_tracks: return jsonify({"success": True, "message": "No tracks in wishlist to clean up", "removed_count": 0}) - + print(f"📋 [Wishlist Cleanup] Found {len(wishlist_tracks)} tracks in wishlist") removed_count = 0 @@ -13399,7 +13482,7 @@ def remove_track_from_wishlist(): return jsonify({"success": False, "error": "No spotify_track_id provided"}), 400 wishlist_service = get_wishlist_service() - success = wishlist_service.remove_track_from_wishlist(spotify_track_id) + success = wishlist_service.remove_track_from_wishlist(spotify_track_id, profile_id=get_current_profile_id()) if success: logger.info(f"Successfully removed track from wishlist: {spotify_track_id}") @@ -13426,7 +13509,7 @@ def remove_album_from_wishlist(): return jsonify({"success": False, "error": "No album_id provided"}), 400 wishlist_service = get_wishlist_service() - all_tracks = wishlist_service.get_wishlist_tracks_for_download() + all_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=get_current_profile_id()) # Find all tracks that belong to this album tracks_to_remove = [] @@ -13470,8 +13553,9 @@ def remove_album_from_wishlist(): # Remove all matching tracks removed_count = 0 + album_remove_pid = get_current_profile_id() for spotify_track_id in tracks_to_remove: - if wishlist_service.remove_track_from_wishlist(spotify_track_id): + if wishlist_service.remove_track_from_wishlist(spotify_track_id, profile_id=album_remove_pid): removed_count += 1 if removed_count > 0: @@ -13503,8 +13587,9 @@ def remove_batch_from_wishlist(): wishlist_service = get_wishlist_service() removed = 0 + pid = get_current_profile_id() for track_id in spotify_track_ids: - if wishlist_service.remove_track_from_wishlist(track_id): + if wishlist_service.remove_track_from_wishlist(track_id, profile_id=pid): removed += 1 logger.info(f"Batch removed {removed} track(s) from wishlist") @@ -13586,7 +13671,8 @@ def add_album_track_to_wishlist(): spotify_track_data=spotify_track_data, failure_reason="Added from library (incomplete album)", source_type=source_type, - source_context=enhanced_source_context + source_context=enhanced_source_context, + profile_id=get_current_profile_id() ) if success: @@ -13695,7 +13781,7 @@ def _get_quality_tier_from_extension(file_path): return ('unknown', 999) -def _run_quality_scanner(scope='watchlist'): +def _run_quality_scanner(scope='watchlist', profile_id=1): """Main quality scanner worker function""" from core.wishlist_service import get_wishlist_service from database.music_database import MusicDatabase @@ -13746,7 +13832,7 @@ def _run_quality_scanner(scope='watchlist'): if scope == 'watchlist': # Get watchlist artists - watchlist_artists = db.get_watchlist_artists() + watchlist_artists = db.get_watchlist_artists(profile_id=profile_id) if not watchlist_artists: with quality_scanner_lock: quality_scanner_state["status"] = "finished" @@ -13937,7 +14023,8 @@ def _run_quality_scanner(scope='watchlist'): spotify_track_data=matched_track_data, failure_reason=f"Low quality - {tier_name.replace('_', ' ').title()} format", source_type='quality_scanner', - source_context=source_context + source_context=source_context, + profile_id=profile_id ) if success: @@ -14206,8 +14293,9 @@ def start_quality_scan(): quality_scanner_state["results"] = [] quality_scanner_state["error_message"] = "" - # Submit worker - quality_scanner_executor.submit(_run_quality_scanner, scope) + # Submit worker (capture profile_id before thread) + scan_profile_id = get_current_profile_id() + quality_scanner_executor.submit(_run_quality_scanner, scope, scan_profile_id) add_activity_item("🔍", "Quality Scan Started", f"Scanning {scope} tracks", "Now") @@ -14832,7 +14920,8 @@ def _process_failed_tracks_to_wishlist_exact(batch_id): success = wishlist_service.add_failed_track_from_modal( track_info=failed_track_info, source_type='playlist', - source_context=source_context + source_context=source_context, + profile_id=batch.get('profile_id', 1) ) if success: wishlist_added_count += 1 @@ -16618,9 +16707,11 @@ def start_playlist_missing_downloads(playlist_id): 'queue_index': 0, # Track state management (replicating sync.py) 'permanently_failed_tracks': [], - 'cancelled_tracks': set() + 'cancelled_tracks': set(), + # Profile context for failed track wishlist re-adds + 'profile_id': get_current_profile_id() } - + for i, track_entry in enumerate(missing_tracks): task_id = str(uuid.uuid4()) # Extract track data and original track index from frontend @@ -17126,10 +17217,11 @@ def cancel_download_task(): success = wishlist_service.add_spotify_track_to_wishlist( spotify_track_data=spotify_track_data, failure_reason="Download cancelled by user", - source_type="playlist", - source_context=source_context + source_type="playlist", + source_context=source_context, + profile_id=get_current_profile_id() ) - + if success: print(f"✅ Added cancelled track '{track_info.get('name')}' to wishlist.") else: @@ -17587,8 +17679,9 @@ def _add_cancelled_task_to_wishlist(task): success = wishlist_service.add_spotify_track_to_wishlist( spotify_track_data=spotify_track_data, failure_reason="Download cancelled by user (v2)", - source_type="playlist", - source_context=source_context + source_type="playlist", + source_context=source_context, + profile_id=get_current_profile_id() ) if success: @@ -17772,6 +17865,8 @@ def start_missing_tracks_process(playlist_id): 'cancelled_tracks': set(), 'queue_index': 0, 'analysis_total': len(tracks), + # Profile context for failed track wishlist re-adds + 'profile_id': get_current_profile_id(), 'analysis_processed': 0, 'analysis_results': [], 'force_download_all': force_download_all, # Pass the force flag to the batch @@ -17834,9 +17929,11 @@ def start_missing_downloads(): 'queue_index': 0, # Track state management (replicating sync.py) 'permanently_failed_tracks': [], - 'cancelled_tracks': set() + 'cancelled_tracks': set(), + # Profile context for failed track wishlist re-adds + 'profile_id': get_current_profile_id() } - + for track_index, track_data in enumerate(missing_tracks): task_id = str(uuid.uuid4()) download_tasks[task_id] = { @@ -21013,7 +21110,7 @@ def save_discover_download_snapshot(): downloads = data['downloads'] db = get_database() - db.save_bubble_snapshot('discover_downloads', downloads) + db.save_bubble_snapshot('discover_downloads', downloads, profile_id=get_current_profile_id()) download_count = len(downloads) print(f"📸 Saved discover download snapshot: {download_count} downloads") @@ -21042,7 +21139,7 @@ def hydrate_discover_downloads(): from datetime import datetime, timedelta db = get_database() - snapshot = db.get_bubble_snapshot('discover_downloads') + snapshot = db.get_bubble_snapshot('discover_downloads', profile_id=get_current_profile_id()) # Load snapshot if it exists if not snapshot: @@ -21062,7 +21159,7 @@ def hydrate_discover_downloads(): cutoff = datetime.now() - timedelta(hours=48) if snapshot_dt < cutoff: print(f"🧹 Cleaning up old discover download snapshot from {snapshot_time}") - db.delete_bubble_snapshot('discover_downloads') + db.delete_bubble_snapshot('discover_downloads', profile_id=get_current_profile_id()) return jsonify({ 'success': True, 'downloads': {}, @@ -21090,7 +21187,7 @@ def hydrate_discover_downloads(): # If no active processes exist, the app likely restarted - clean up snapshots if not current_processes: print(f"🧹 No active processes found - app likely restarted, cleaning up discover download snapshot") - db.delete_bubble_snapshot('discover_downloads') + db.delete_bubble_snapshot('discover_downloads', profile_id=get_current_profile_id()) return jsonify({ 'success': True, 'downloads': {}, @@ -21162,7 +21259,7 @@ def save_artist_bubble_snapshot(): bubbles = data['bubbles'] db = get_database() - db.save_bubble_snapshot('artist_bubbles', bubbles) + db.save_bubble_snapshot('artist_bubbles', bubbles, profile_id=get_current_profile_id()) bubble_count = len(bubbles) print(f"📸 Saved artist bubble snapshot: {bubble_count} artists") @@ -21191,7 +21288,7 @@ def hydrate_artist_bubbles(): from datetime import datetime, timedelta db = get_database() - snapshot = db.get_bubble_snapshot('artist_bubbles') + snapshot = db.get_bubble_snapshot('artist_bubbles', profile_id=get_current_profile_id()) # Load snapshot if it exists if not snapshot: @@ -21211,7 +21308,7 @@ def hydrate_artist_bubbles(): cutoff = datetime.now() - timedelta(hours=48) if snapshot_dt < cutoff: print(f"🧹 Cleaning up old snapshot from {snapshot_time}") - db.delete_bubble_snapshot('artist_bubbles') + db.delete_bubble_snapshot('artist_bubbles', profile_id=get_current_profile_id()) return jsonify({ 'success': True, 'bubbles': {}, @@ -21239,7 +21336,7 @@ def hydrate_artist_bubbles(): # If no active processes exist, the app likely restarted - clean up snapshots if not current_processes: print(f"🧹 No active processes found - app likely restarted, cleaning up snapshot") - db.delete_bubble_snapshot('artist_bubbles') + db.delete_bubble_snapshot('artist_bubbles', profile_id=get_current_profile_id()) return jsonify({ 'success': True, 'bubbles': {}, @@ -21334,7 +21431,7 @@ def save_search_bubble_snapshot(): bubbles = data['bubbles'] db = get_database() - db.save_bubble_snapshot('search_bubbles', bubbles) + db.save_bubble_snapshot('search_bubbles', bubbles, profile_id=get_current_profile_id()) bubble_count = len(bubbles) print(f"📸 Saved search bubble snapshot: {bubble_count} albums/tracks") @@ -21363,7 +21460,7 @@ def hydrate_search_bubbles(): from datetime import datetime, timedelta db = get_database() - snapshot = db.get_bubble_snapshot('search_bubbles') + snapshot = db.get_bubble_snapshot('search_bubbles', profile_id=get_current_profile_id()) # Load snapshot if it exists if not snapshot: @@ -21383,7 +21480,7 @@ def hydrate_search_bubbles(): cutoff = datetime.now() - timedelta(hours=48) if snapshot_dt < cutoff: print(f"🧹 Cleaning up old search snapshot from {snapshot_time}") - db.delete_bubble_snapshot('search_bubbles') + db.delete_bubble_snapshot('search_bubbles', profile_id=get_current_profile_id()) return jsonify({ 'success': True, 'bubbles': {}, @@ -21411,7 +21508,7 @@ def hydrate_search_bubbles(): # If no active processes exist, the app likely restarted - clean up snapshots if not current_processes: print(f"🧹 No active processes found - app likely restarted, cleaning up search snapshot") - db.delete_bubble_snapshot('search_bubbles') + db.delete_bubble_snapshot('search_bubbles', profile_id=get_current_profile_id()) return jsonify({ 'success': True, 'bubbles': {}, @@ -21484,6 +21581,192 @@ def hydrate_search_bubbles(): 'error': str(e) }), 500 +# --- Profile API Endpoints --- + +@app.route('/api/profiles', methods=['GET']) +def list_profiles(): + """List all profiles""" + try: + database = get_database() + profiles = database.get_all_profiles() + return jsonify({'success': True, 'profiles': profiles}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles', methods=['POST']) +def create_profile(): + """Create a new profile (admin only)""" + try: + # Check that requester is admin + database = get_database() + current = database.get_profile(get_current_profile_id()) + if current and not current['is_admin']: + return jsonify({'success': False, 'error': 'Admin only'}), 403 + + data = request.json or {} + name = data.get('name', '').strip() + if not name: + return jsonify({'success': False, 'error': 'Name is required'}), 400 + + avatar_color = data.get('avatar_color', '#6366f1') + avatar_url = data.get('avatar_url') or None + pin = data.get('pin') + pin_hash = None + if pin: + from werkzeug.security import generate_password_hash + pin_hash = generate_password_hash(pin, method='pbkdf2:sha256') + + profile_id = database.create_profile(name, avatar_color, pin_hash, is_admin=False, avatar_url=avatar_url) + if profile_id is None: + return jsonify({'success': False, 'error': 'Profile name already exists'}), 409 + + return jsonify({'success': True, 'profile_id': profile_id}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles/', methods=['PUT']) +def update_profile(profile_id): + """Update a profile (admin or self)""" + try: + database = get_database() + current_pid = get_current_profile_id() + current = database.get_profile(current_pid) + if not current: + return jsonify({'success': False, 'error': 'Current profile not found'}), 404 + + # Only admin or self can update + if not current['is_admin'] and current_pid != profile_id: + return jsonify({'success': False, 'error': 'Unauthorized'}), 403 + + data = request.json or {} + kwargs = {} + if 'name' in data: + name = data['name'].strip() + if not name: + return jsonify({'success': False, 'error': 'Name cannot be empty'}), 400 + kwargs['name'] = name + if 'avatar_color' in data: + kwargs['avatar_color'] = data['avatar_color'] + if 'avatar_url' in data: + kwargs['avatar_url'] = data['avatar_url'] or None + if 'is_admin' in data and current['is_admin']: + # Prevent demoting the last admin + if not data['is_admin']: + all_profiles = database.get_all_profiles() + admin_count = sum(1 for p in all_profiles if p['is_admin']) + target = database.get_profile(profile_id) + if target and target['is_admin'] and admin_count <= 1: + return jsonify({'success': False, 'error': 'Cannot remove the last admin'}), 400 + kwargs['is_admin'] = int(data['is_admin']) + + success = database.update_profile(profile_id, **kwargs) + return jsonify({'success': success}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles/', methods=['DELETE']) +def delete_profile(profile_id): + """Delete a profile (admin only, can't delete self)""" + try: + database = get_database() + current_pid = get_current_profile_id() + current = database.get_profile(current_pid) + + if not current or not current['is_admin']: + return jsonify({'success': False, 'error': 'Admin only'}), 403 + if current_pid == profile_id: + return jsonify({'success': False, 'error': 'Cannot delete your own profile'}), 400 + + target = database.get_profile(profile_id) + if not target: + return jsonify({'success': False, 'error': 'Profile not found'}), 404 + + success = database.delete_profile(profile_id) + return jsonify({'success': success}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles/select', methods=['POST']) +def select_profile(): + """Select a profile (validates PIN if set)""" + try: + data = request.json or {} + try: + profile_id = int(data.get('profile_id', 0)) + except (TypeError, ValueError): + return jsonify({'success': False, 'error': 'Invalid profile_id'}), 400 + pin = data.get('pin', '') + + if not profile_id: + return jsonify({'success': False, 'error': 'profile_id required'}), 400 + + database = get_database() + profile = database.get_profile(profile_id) + if not profile: + return jsonify({'success': False, 'error': 'Profile not found'}), 404 + + # Only enforce PIN when multiple profiles exist (PIN protects against profile switching) + all_profiles = database.get_all_profiles() + if len(all_profiles) > 1 and profile['has_pin']: + if not pin: + return jsonify({'success': False, 'error': 'PIN required', 'pin_required': True}), 401 + if not database.verify_profile_pin(profile_id, pin): + return jsonify({'success': False, 'error': 'Invalid PIN'}), 401 + + session['profile_id'] = profile_id + return jsonify({'success': True, 'profile': profile}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles/current', methods=['GET']) +def get_current_profile(): + """Get the currently selected profile from session""" + try: + pid = session.get('profile_id') + if not pid: + return jsonify({'success': False, 'error': 'No profile selected'}), 200 + + database = get_database() + profile = database.get_profile(pid) + if not profile: + session.pop('profile_id', None) + return jsonify({'success': False, 'error': 'Profile not found'}), 200 + + return jsonify({'success': True, 'profile': profile}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles/logout', methods=['POST']) +def logout_profile(): + """Clear session — back to profile picker""" + session.pop('profile_id', None) + return jsonify({'success': True}) + +@app.route('/api/profiles//set-pin', methods=['POST']) +def set_profile_pin(profile_id): + """Set or change PIN for a profile (admin or self)""" + try: + database = get_database() + current_pid = get_current_profile_id() + current = database.get_profile(current_pid) + + if not current or (not current['is_admin'] and current_pid != profile_id): + return jsonify({'success': False, 'error': 'Unauthorized'}), 403 + + data = request.json or {} + pin = data.get('pin', '') + + if pin: + from werkzeug.security import generate_password_hash + pin_hash = generate_password_hash(pin, method='pbkdf2:sha256') + else: + pin_hash = None # Remove PIN + + success = database.update_profile(profile_id, pin_hash=pin_hash) + return jsonify({'success': success}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + # --- Watchlist API Endpoints --- @app.route('/api/watchlist/count', methods=['GET']) @@ -21491,7 +21774,7 @@ def get_watchlist_count(): """Get the number of artists in the watchlist""" try: database = get_database() - count = database.get_watchlist_count() + count = database.get_watchlist_count(profile_id=get_current_profile_id()) # Calculate time until next auto-scanning next_run_in_seconds = 0 @@ -21513,7 +21796,7 @@ def get_watchlist_artists(): """Get all artists in the watchlist with cached images""" try: database = get_database() - watchlist_artists = database.get_watchlist_artists() + watchlist_artists = database.get_watchlist_artists(profile_id=get_current_profile_id()) # Convert to JSON serializable format (images are cached from watchlist scans) artists_data = [] @@ -21554,7 +21837,7 @@ def add_to_watchlist(): return jsonify({"success": False, "error": "Missing artist_id or artist_name"}), 400 database = get_database() - success = database.add_artist_to_watchlist(artist_id, artist_name) + success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id()) if success: # Detect ID type: iTunes IDs are purely numeric, Spotify IDs are alphanumeric @@ -21616,9 +21899,10 @@ def add_to_watchlist(): # Don't fail the add operation if image fetch fails print(f"⚠️ Could not fetch artist image for {artist_name}: {img_error}") - # Push updated count to all WebSocket clients immediately + # Push updated count to this profile's WebSocket room immediately try: - socketio.emit('watchlist:count', _build_watchlist_count_payload()) + pid = get_current_profile_id() + socketio.emit('watchlist:count', _build_watchlist_count_payload(profile_id=pid), room=f'profile:{pid}') except Exception: pass return jsonify({"success": True, "message": f"Added {artist_name} to watchlist"}) @@ -21640,12 +21924,13 @@ def remove_from_watchlist(): return jsonify({"success": False, "error": "Missing artist_id"}), 400 database = get_database() - success = database.remove_artist_from_watchlist(artist_id) + success = database.remove_artist_from_watchlist(artist_id, profile_id=get_current_profile_id()) if success: - # Push updated count to all WebSocket clients immediately + # Push updated count to this profile's WebSocket room immediately try: - socketio.emit('watchlist:count', _build_watchlist_count_payload()) + pid = get_current_profile_id() + socketio.emit('watchlist:count', _build_watchlist_count_payload(profile_id=pid), room=f'profile:{pid}') except Exception: pass return jsonify({"success": True, "message": "Removed artist from watchlist"}) @@ -21677,11 +21962,11 @@ def add_batch_to_watchlist(): continue # Check if already watched - if database.is_artist_in_watchlist(artist_id): + if database.is_artist_in_watchlist(artist_id, profile_id=get_current_profile_id()): skipped += 1 continue - success = database.add_artist_to_watchlist(artist_id, artist_name) + success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id()) if success: added += 1 # Cache artist image @@ -21730,7 +22015,8 @@ def watchlist_all_unwatched_library_artists(): letter='all', page=1, limit=99999, - watchlist_filter='unwatched' + watchlist_filter='unwatched', + profile_id=get_current_profile_id() ) unwatched_artists = result.get('artists', []) @@ -21758,11 +22044,11 @@ def watchlist_all_unwatched_library_artists(): continue # Check if already watched (shouldn't be since we filtered, but safety check) - if database.is_artist_in_watchlist(artist_id): + if database.is_artist_in_watchlist(artist_id, profile_id=get_current_profile_id()): skipped_already += 1 continue - success = database.add_artist_to_watchlist(artist_id, artist_name) + success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id()) if success: added += 1 # Use library thumb_url if available (no HTTP calls needed) @@ -21805,7 +22091,7 @@ def remove_batch_from_watchlist(): database = get_database() removed = 0 for artist_id in artist_ids: - if database.remove_artist_from_watchlist(artist_id): + if database.remove_artist_from_watchlist(artist_id, profile_id=get_current_profile_id()): removed += 1 return jsonify({ @@ -21829,8 +22115,8 @@ def check_watchlist_status(): return jsonify({"success": False, "error": "Missing artist_id"}), 400 database = get_database() - is_watching = database.is_artist_in_watchlist(artist_id) - + is_watching = database.is_artist_in_watchlist(artist_id, profile_id=get_current_profile_id()) + return jsonify({"success": True, "is_watching": is_watching}) except Exception as e: @@ -21867,6 +22153,7 @@ def start_watchlist_scan(): return jsonify({"success": False, "error": "Watchlist scan is already in progress."}), 409 # Start the scan in a background thread + scan_profile_id = get_current_profile_id() def run_scan(): _enrichment_was_running = False _itunes_enrichment_was_running = False @@ -21882,9 +22169,9 @@ def start_watchlist_scan(): watchlist_auto_scanning_timestamp = time.time() print(f"🔒 [Manual Watchlist Scan] Flag set at timestamp {watchlist_auto_scanning_timestamp}") - # Get list of artists to scan + # Get list of artists to scan (for the current profile) database = get_database() - watchlist_artists = database.get_watchlist_artists() + watchlist_artists = database.get_watchlist_artists(profile_id=scan_profile_id) # Apply global overrides if enabled _apply_watchlist_global_overrides(watchlist_artists) @@ -22113,13 +22400,13 @@ def start_watchlist_scan(): # If Spotify is authenticated, also require Spotify IDs to be present spotify_authenticated = spotify_client and spotify_client.is_spotify_authenticated() - if database.has_fresh_similar_artists(source_artist_id, days_threshold=30, require_spotify=spotify_authenticated): + if database.has_fresh_similar_artists(source_artist_id, days_threshold=30, require_spotify=spotify_authenticated, profile_id=scan_profile_id): print(f" Similar artists for {artist.artist_name} are cached and fresh") # Still backfill missing iTunes IDs - scanner._backfill_similar_artists_itunes_ids(source_artist_id) + scanner._backfill_similar_artists_itunes_ids(source_artist_id, profile_id=scan_profile_id) else: print(f" Fetching similar artists for {artist.artist_name}...") - scanner.update_similar_artists(artist) + scanner.update_similar_artists(artist, profile_id=scan_profile_id) print(f" Similar artists updated for {artist.artist_name}") except Exception as similar_error: print(f" ⚠️ Failed to update similar artists for {artist.artist_name}: {similar_error}") @@ -22184,7 +22471,7 @@ def start_watchlist_scan(): print("🎵 Starting discovery pool population...") watchlist_scan_state['current_phase'] = 'populating_discovery_pool' try: - scanner.populate_discovery_pool() + scanner.populate_discovery_pool(profile_id=scan_profile_id) print("✅ Discovery pool population complete") except Exception as discovery_error: print(f"⚠️ Error populating discovery pool: {discovery_error}") @@ -22644,30 +22931,41 @@ def _update_similar_artists_worker(): print("🎵 [Similar Artists] Starting similar artists update...") database = get_database() - watchlist_artists = database.get_watchlist_artists() + all_profiles = database.get_all_profiles() - if not watchlist_artists: + # Build per-profile artist lists and deduplicate for API calls + # artist_key -> (artist_obj, [profile_ids]) + artist_profiles = {} + for p in all_profiles: + for artist in database.get_watchlist_artists(profile_id=p['id']): + key = (artist.spotify_artist_id or '', artist.itunes_artist_id or '', artist.artist_name.lower()) + if key not in artist_profiles: + artist_profiles[key] = (artist, []) + artist_profiles[key][1].append(p['id']) + + if not artist_profiles: similar_artists_update_state['status'] = 'completed' print("📭 [Similar Artists] No watchlist artists to process") return - similar_artists_update_state['total_artists'] = len(watchlist_artists) - print(f"📊 [Similar Artists] Processing {len(watchlist_artists)} watchlist artists") + similar_artists_update_state['total_artists'] = len(artist_profiles) + print(f"📊 [Similar Artists] Processing {len(artist_profiles)} unique watchlist artists across {len(all_profiles)} profiles") scanner = get_watchlist_scanner(spotify_client) - for idx, artist in enumerate(watchlist_artists, 1): + for idx, (key, (artist, profile_ids)) in enumerate(artist_profiles.items(), 1): try: similar_artists_update_state['artists_processed'] = idx similar_artists_update_state['current_artist'] = artist.artist_name - print(f"[{idx}/{len(watchlist_artists)}] Updating similar artists for {artist.artist_name}") + print(f"[{idx}/{len(artist_profiles)}] Updating similar artists for {artist.artist_name} (profiles: {profile_ids})") - # Update similar artists for this artist - scanner.update_similar_artists(artist, limit=10) + # Update similar artists for each profile that watches this artist + for pid in profile_ids: + scanner.update_similar_artists(artist, limit=10, profile_id=pid) # Rate limiting - if idx < len(watchlist_artists): + if idx < len(artist_profiles): time.sleep(2.0) # 2 seconds between artists except Exception as artist_error: @@ -22678,7 +22976,7 @@ def _update_similar_artists_worker(): similar_artists_update_state['status'] = 'completed' similar_artists_update_state['current_artist'] = None - print(f"✅ [Similar Artists] Update complete! Processed {len(watchlist_artists)} artists") + print(f"✅ [Similar Artists] Update complete! Processed {len(artist_profiles)} artists") except Exception as e: print(f"❌ [Similar Artists] Critical error: {e}") @@ -22824,10 +23122,12 @@ def _process_watchlist_scan_automatically(): from core.watchlist_scanner import get_watchlist_scanner from database.music_database import get_database - # Check if we have artists to scan and Spotify client is available + # Check if we have artists to scan across all profiles database = get_database() - watchlist_count = database.get_watchlist_count() - print(f"🔍 [Auto-Watchlist] Watchlist count check: {watchlist_count} artists found") + # Auto-scan covers all profiles + all_profiles = database.get_all_profiles() + watchlist_count = sum(database.get_watchlist_count(profile_id=p['id']) for p in all_profiles) + print(f"🔍 [Auto-Watchlist] Watchlist count check: {watchlist_count} artists found across {len(all_profiles)} profiles") if watchlist_count == 0: print("ℹ️ [Auto-Watchlist] No artists in watchlist for auto-scanning.") @@ -22849,8 +23149,10 @@ def _process_watchlist_scan_automatically(): print(f"👁️ [Auto-Watchlist] Found {watchlist_count} artists in watchlist, starting automatic scan...") - # Get list of artists to scan - watchlist_artists = database.get_watchlist_artists() + # Get list of artists to scan (all profiles combined for auto-scan) + watchlist_artists = [] + for p in all_profiles: + watchlist_artists.extend(database.get_watchlist_artists(profile_id=p['id'])) scanner = get_watchlist_scanner(spotify_client) # Apply global overrides if enabled @@ -23102,11 +23404,12 @@ def _process_watchlist_scan_automatically(): print(f"Automatic watchlist scan completed: {len(successful_scans)}/{len(scan_results)} artists scanned successfully") print(f"Found {total_new_tracks} new tracks, added {total_added_to_wishlist} to wishlist") - # Populate discovery pool from similar artists + # Populate discovery pool from similar artists (per-profile) print("🎵 Starting discovery pool population...") watchlist_scan_state['current_phase'] = 'populating_discovery_pool' try: - scanner.populate_discovery_pool() + for p in all_profiles: + scanner.populate_discovery_pool(profile_id=p['id']) print("✅ Discovery pool population complete") except Exception as discovery_error: print(f"⚠️ Error populating discovery pool: {discovery_error}") @@ -23235,12 +23538,13 @@ def get_discover_hero(): itunes_client = iTunesClient() # Get top similar artists (excluding watchlist, cycled by last_featured) - similar_artists = database.get_top_similar_artists(limit=50) + pid = get_current_profile_id() + similar_artists = database.get_top_similar_artists(limit=50, profile_id=pid) # FALLBACK: If no similar artists exist, use watchlist artists for Hero section if not similar_artists: print("[Discover Hero] No similar artists found, falling back to watchlist artists") - watchlist_artists = database.get_watchlist_artists() + watchlist_artists = database.get_watchlist_artists(profile_id=pid) if not watchlist_artists: return jsonify({"success": True, "artists": [], "source": active_source}) @@ -23396,7 +23700,7 @@ def get_discover_similar_artists(): database = get_database() active_source = _get_active_discovery_source() - similar_artists = database.get_top_similar_artists(limit=200) + similar_artists = database.get_top_similar_artists(limit=200, profile_id=get_current_profile_id()) if not similar_artists: return jsonify({"success": True, "artists": [], "source": active_source, "count": 0}) @@ -23501,7 +23805,7 @@ def get_discover_recent_releases(): active_source = _get_active_discovery_source() # Get cached recent albums filtered by source (max 20) - albums = database.get_discovery_recent_albums(limit=20, source=active_source) + albums = database.get_discovery_recent_albums(limit=20, source=active_source, profile_id=get_current_profile_id()) return jsonify({"success": True, "albums": albums, "source": active_source}) @@ -23520,13 +23824,14 @@ def get_discover_release_radar(): active_source = _get_active_discovery_source() # Try source-specific playlist first, then fall back to generic - curated_track_ids = database.get_curated_playlist(f'release_radar_{active_source}') + pid = get_current_profile_id() + curated_track_ids = database.get_curated_playlist(f'release_radar_{active_source}', profile_id=pid) if not curated_track_ids: - curated_track_ids = database.get_curated_playlist('release_radar') + curated_track_ids = database.get_curated_playlist('release_radar', profile_id=pid) if curated_track_ids: # Use curated selection - fetch track data from discovery pool filtered by source - discovery_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False, source=active_source) + discovery_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False, source=active_source, profile_id=pid) # Build lookup dict with source-appropriate IDs tracks_by_id = {} @@ -23583,13 +23888,14 @@ def get_discover_weekly(): active_source = _get_active_discovery_source() # Try source-specific playlist first, then fall back to generic - curated_track_ids = database.get_curated_playlist(f'discovery_weekly_{active_source}') + pid = get_current_profile_id() + curated_track_ids = database.get_curated_playlist(f'discovery_weekly_{active_source}', profile_id=pid) if not curated_track_ids: - curated_track_ids = database.get_curated_playlist('discovery_weekly') + curated_track_ids = database.get_curated_playlist('discovery_weekly', profile_id=pid) if curated_track_ids: # Use curated selection - fetch track data from discovery pool filtered by source - discovery_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False, source=active_source) + discovery_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False, source=active_source, profile_id=pid) # Build lookup dict with source-appropriate IDs tracks_by_id = {} @@ -23649,19 +23955,22 @@ def refresh_discover_data(): print("[Discover Refresh] Starting forced refresh of discover data...") + refresh_pid = get_current_profile_id() + # Cache recent albums from watchlist and similar artists print("[Discover Refresh] Caching recent albums...") - scanner.cache_discovery_recent_albums() + scanner.cache_discovery_recent_albums(profile_id=refresh_pid) # Curate playlists print("[Discover Refresh] Curating discovery playlists...") - scanner.curate_discovery_playlists() + scanner.curate_discovery_playlists(profile_id=refresh_pid) # Get counts for response active_source = _get_active_discovery_source() - recent_albums = database.get_discovery_recent_albums(limit=100, source=active_source) - release_radar = database.get_curated_playlist(f'release_radar_{active_source}') or [] - discovery_weekly = database.get_curated_playlist(f'discovery_weekly_{active_source}') or [] + pid = get_current_profile_id() + recent_albums = database.get_discovery_recent_albums(limit=100, source=active_source, profile_id=pid) + release_radar = database.get_curated_playlist(f'release_radar_{active_source}', profile_id=pid) or [] + discovery_weekly = database.get_curated_playlist(f'discovery_weekly_{active_source}', profile_id=pid) or [] print(f"[Discover Refresh] Complete! Recent albums: {len(recent_albums)}, Release Radar: {len(release_radar)} tracks, Discovery Weekly: {len(discovery_weekly)} tracks") @@ -23690,30 +23999,31 @@ def diagnose_discover_data(): try: database = get_database() active_source = _get_active_discovery_source() + pid = get_current_profile_id() with database._get_connection() as conn: cursor = conn.cursor() # Similar artists stats - cursor.execute("SELECT COUNT(*) as total FROM similar_artists") + cursor.execute("SELECT COUNT(*) as total FROM similar_artists WHERE profile_id = ?", (pid,)) total_similar = cursor.fetchone()['total'] - cursor.execute("SELECT COUNT(*) as count FROM similar_artists WHERE similar_artist_itunes_id IS NOT NULL") + cursor.execute("SELECT COUNT(*) as count FROM similar_artists WHERE similar_artist_itunes_id IS NOT NULL AND profile_id = ?", (pid,)) similar_with_itunes = cursor.fetchone()['count'] - cursor.execute("SELECT COUNT(*) as count FROM similar_artists WHERE similar_artist_spotify_id IS NOT NULL") + cursor.execute("SELECT COUNT(*) as count FROM similar_artists WHERE similar_artist_spotify_id IS NOT NULL AND profile_id = ?", (pid,)) similar_with_spotify = cursor.fetchone()['count'] # Discovery pool stats - cursor.execute("SELECT source, COUNT(*) as count FROM discovery_pool GROUP BY source") + cursor.execute("SELECT source, COUNT(*) as count FROM discovery_pool WHERE profile_id = ? GROUP BY source", (pid,)) pool_by_source = {row['source']: row['count'] for row in cursor.fetchall()} # Recent albums stats - cursor.execute("SELECT source, COUNT(*) as count FROM discovery_recent_albums GROUP BY source") + cursor.execute("SELECT source, COUNT(*) as count FROM discovery_recent_albums WHERE profile_id = ? GROUP BY source", (pid,)) albums_by_source = {row['source']: row['count'] for row in cursor.fetchall()} # Curated playlists - cursor.execute("SELECT playlist_type, track_ids_json FROM discovery_curated_playlists") + cursor.execute("SELECT playlist_type, track_ids_json FROM discovery_curated_playlists WHERE profile_id = ?", (pid,)) playlists = {} for row in cursor.fetchall(): import json @@ -23721,10 +24031,10 @@ def diagnose_discover_data(): playlists[row['playlist_type']] = len(track_ids) # Watchlist artists - cursor.execute("SELECT COUNT(*) as total FROM watchlist_artists") + cursor.execute("SELECT COUNT(*) as total FROM watchlist_artists WHERE profile_id = ?", (pid,)) total_watchlist = cursor.fetchone()['total'] - cursor.execute("SELECT COUNT(*) as count FROM watchlist_artists WHERE itunes_artist_id IS NOT NULL") + cursor.execute("SELECT COUNT(*) as count FROM watchlist_artists WHERE itunes_artist_id IS NOT NULL AND profile_id = ?", (pid,)) watchlist_with_itunes = cursor.fetchone()['count'] return jsonify({ @@ -29741,11 +30051,11 @@ def _build_status_payload(): 'active_media_server': config_manager.get_active_media_server() } -def _build_watchlist_count_payload(): +def _build_watchlist_count_payload(profile_id=1): """Build the same payload used by GET /api/watchlist/count.""" try: database = get_database() - count = database.get_watchlist_count() + count = database.get_watchlist_count(profile_id=profile_id) except Exception: count = 0 next_run_in_seconds = 0 @@ -29768,11 +30078,15 @@ def _emit_service_status_loop(): logger.debug(f"Error emitting service status: {e}") def _emit_watchlist_count_loop(): - """Background thread that pushes watchlist count every 30 seconds.""" + """Background thread that pushes watchlist count every 30 seconds to each profile room.""" while True: socketio.sleep(30) try: - socketio.emit('watchlist:count', _build_watchlist_count_payload()) + database = get_database() + profiles = database.get_all_profiles() + for profile in profiles: + pid = profile['id'] + socketio.emit('watchlist:count', _build_watchlist_count_payload(profile_id=pid), room=f'profile:{pid}') except Exception as e: logger.debug(f"Error emitting watchlist count: {e}") @@ -29823,6 +30137,18 @@ def handle_download_unsubscribe(data): leave_room(f'batch:{bid}') logger.debug(f"Client unsubscribed from batches: {batch_ids}") +@socketio.on('profile:join') +def handle_profile_join(data): + """Client joins a profile room for scoped WebSocket emits (watchlist/wishlist counts).""" + profile_id = data.get('profile_id') + if profile_id: + # Leave any previous profile rooms + old_id = data.get('old_profile_id') + if old_id: + leave_room(f'profile:{old_id}') + join_room(f'profile:{profile_id}') + logger.debug(f"Client joined profile room: profile:{profile_id}") + # --- Phase 2: Dashboard emitters --- def _emit_system_stats_loop(): @@ -29857,13 +30183,18 @@ def _emit_db_stats_loop(): logger.debug(f"Error emitting db stats: {e}") def _emit_wishlist_count_loop(): - """Background thread that pushes wishlist count every 30 seconds.""" + """Background thread that pushes wishlist count every 30 seconds to each profile room.""" while True: socketio.sleep(30) try: from core.wishlist_service import get_wishlist_service - count = get_wishlist_service().get_wishlist_count() - socketio.emit('dashboard:wishlist_count', {'count': count}) + ws = get_wishlist_service() + database = get_database() + profiles = database.get_all_profiles() + for profile in profiles: + pid = profile['id'] + count = ws.get_wishlist_count(profile_id=pid) + socketio.emit('dashboard:wishlist_count', {'count': count}, room=f'profile:{pid}') except Exception as e: logger.debug(f"Error emitting wishlist count: {e}") diff --git a/webui/index.html b/webui/index.html index 76abc36f..77b189bd 100644 --- a/webui/index.html +++ b/webui/index.html @@ -11,6 +11,63 @@ + + +