Add multi-profile support with Netflix-style profile picker

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
This commit is contained in:
Broque Thomas 2026-03-04 10:46:08 -08:00
parent 84de4ad16b
commit 4fee005dee
8 changed files with 2514 additions and 375 deletions

View file

@ -686,13 +686,14 @@ class WatchlistScanner:
# Check if we have fresh similar artists cached (< 30 days old) # Check if we have fresh similar artists cached (< 30 days old)
# If Spotify is authenticated, also require Spotify IDs to be present # If Spotify is authenticated, also require Spotify IDs to be present
spotify_authenticated = self.spotify_client and self.spotify_client.is_spotify_authenticated() 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") 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) # 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: else:
logger.info(f"Fetching similar artists for {watchlist_artist.artist_name}...") 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}") logger.info(f"Similar artists updated for {watchlist_artist.artist_name}")
except Exception as similar_error: except Exception as similar_error:
logger.warning(f"Failed to update similar artists for {watchlist_artist.artist_name}: {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 '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( success = self.database.add_to_wishlist(
spotify_track_data=spotify_track_data, spotify_track_data=spotify_track_data,
failure_reason="Missing from library (found by watchlist scan)", failure_reason="Missing from library (found by watchlist scan)",
@ -1193,7 +1194,8 @@ class WatchlistScanner:
'watchlist_artist_id': watchlist_artist.spotify_artist_id, 'watchlist_artist_id': watchlist_artist.spotify_artist_id,
'album_name': album_name, 'album_name': album_name,
'scan_timestamp': datetime.now().isoformat() 'scan_timestamp': datetime.now().isoformat()
} },
profile_id=getattr(watchlist_artist, 'profile_id', 1)
) )
if success: if success:
@ -1407,20 +1409,21 @@ class WatchlistScanner:
logger.error(f"Error fetching similar artists from MusicMap: {e}") logger.error(f"Error fetching similar artists from MusicMap: {e}")
return [] 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. Backfill missing iTunes IDs for cached similar artists.
This ensures seamless dual-source support without clearing cached data. This ensures seamless dual-source support without clearing cached data.
Args: Args:
source_artist_id: The source artist ID to backfill similar artists for source_artist_id: The source artist ID to backfill similar artists for
profile_id: Profile to scope the backfill to
Returns: Returns:
Number of similar artists updated with iTunes IDs Number of similar artists updated with iTunes IDs
""" """
try: try:
# Get similar artists that are missing iTunes IDs # 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: if not similar_artists:
return 0 return 0
@ -1455,7 +1458,7 @@ class WatchlistScanner:
logger.error(f"Error backfilling similar artists iTunes IDs: {e}") logger.error(f"Error backfilling similar artists iTunes IDs: {e}")
return 0 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. Fetch and store similar artists for a watchlist artist.
Called after each artist scan to build discovery pool. Called after each artist scan to build discovery pool.
@ -1486,7 +1489,8 @@ class WatchlistScanner:
similar_artist_name=similar_artist['name'], similar_artist_name=similar_artist['name'],
similar_artist_spotify_id=similar_artist.get('spotify_id'), similar_artist_spotify_id=similar_artist.get('spotify_id'),
similar_artist_itunes_id=similar_artist.get('itunes_id'), similar_artist_itunes_id=similar_artist.get('itunes_id'),
similarity_rank=rank similarity_rank=rank,
profile_id=profile_id
) )
if success: if success:
@ -1504,7 +1508,7 @@ class WatchlistScanner:
logger.error(f"Error fetching similar artists for {watchlist_artist.artist_name}: {e}") logger.error(f"Error fetching similar artists for {watchlist_artist.artist_name}: {e}")
return False 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. Populate discovery pool with tracks from top similar artists.
Called after watchlist scan completes. Called after watchlist scan completes.
@ -1520,14 +1524,14 @@ class WatchlistScanner:
import random import random
# Check if we should run discovery pool population (prevents over-polling) # 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: if skip_pool_population:
logger.info("Discovery pool was populated recently (< 24 hours ago). Skipping 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...") logger.info("But still refreshing recent albums cache and curated playlists...")
# Still run these even when skipping main pool population # Still run these even when skipping main pool population
self.cache_discovery_recent_albums() self.cache_discovery_recent_albums(profile_id=profile_id)
self.curate_discovery_playlists() self.curate_discovery_playlists(profile_id=profile_id)
return return
logger.info("Populating discovery pool from similar artists...") 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}") logger.info(f"Sources available - Spotify: {spotify_available}, iTunes: {itunes_available}")
# Get top similar artists across all watchlist (ordered by occurrence_count) # 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) similar_artists = self.database.get_top_similar_artists(limit=top_artists_limit, profile_id=profile_id)
if not similar_artists: if not similar_artists:
logger.info("No similar artists found to populate discovery pool from 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...") logger.info("But still caching recent albums from watchlist artists and curating playlists...")
# Still run these even without similar artists - they use watchlist artists # Still run these even without similar artists - they use watchlist artists
self.cache_discovery_recent_albums() self.cache_discovery_recent_albums(profile_id=profile_id)
self.curate_discovery_playlists() self.curate_discovery_playlists(profile_id=profile_id)
return return
logger.info(f"Processing {len(similar_artists)} top similar artists for discovery pool") 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_album_id'] = album_data.get('id')
track_data['itunes_artist_id'] = similar_artist.similar_artist_itunes_id track_data['itunes_artist_id'] = similar_artist.similar_artist_itunes_id
# Add to discovery pool with source # Add to discovery pool with source (scoped to profile)
if self.database.add_to_discovery_pool(track_data, source=source): if self.database.add_to_discovery_pool(track_data, source=source, profile_id=profile_id):
total_tracks_added += 1 total_tracks_added += 1
except Exception as track_error: except Exception as track_error:
@ -1887,7 +1891,7 @@ class WatchlistScanner:
track_data['itunes_album_id'] = album_data.get('id') track_data['itunes_album_id'] = album_data.get('id')
track_data['itunes_artist_id'] = artist_id_for_genres or '' 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 total_tracks_added += 1
except Exception as track_error: except Exception as track_error:
continue continue
@ -1918,23 +1922,23 @@ class WatchlistScanner:
final_count = cursor.fetchone()['count'] final_count = cursor.fetchone()['count']
# Update timestamp to mark when pool was last populated # 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)") logger.info(f"Discovery pool now contains {final_count} total tracks (built over time)")
# Cache recent albums for discovery page # Cache recent albums for discovery page
logger.info("Caching 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 # Curate playlists for consistent daily experience
logger.info("Curating discovery playlists...") logger.info("Curating discovery playlists...")
self.curate_discovery_playlists() self.curate_discovery_playlists(profile_id=profile_id)
except Exception as e: except Exception as e:
logger.error(f"Error populating discovery pool: {e}") logger.error(f"Error populating discovery pool: {e}")
import traceback import traceback
traceback.print_exc() 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. Lightweight incremental update for discovery pool - runs every 6 hours.
@ -1948,13 +1952,13 @@ class WatchlistScanner:
from datetime import datetime, timedelta from datetime import datetime, timedelta
# Check if we should run (prevents over-polling Spotify) # 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.") logger.info("Discovery pool was updated recently (< 6 hours ago). Skipping incremental update.")
return return
logger.info("Starting incremental discovery pool update (watchlist artists only)...") 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: if not watchlist_artists:
logger.info("No watchlist artists to check for incremental update") logger.info("No watchlist artists to check for incremental update")
return return
@ -2042,7 +2046,7 @@ class WatchlistScanner:
'artist_genres': artist_genres '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 total_tracks_added += 1
except Exception as track_error: except Exception as track_error:
@ -2071,7 +2075,7 @@ class WatchlistScanner:
cursor.execute("SELECT COUNT(*) as count FROM discovery_pool") cursor.execute("SELECT COUNT(*) as count FROM discovery_pool")
current_count = cursor.fetchone()['count'] 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") logger.info(f"Discovery pool now contains {current_count} total tracks")
except Exception as e: except Exception as e:
@ -2079,7 +2083,7 @@ class WatchlistScanner:
import traceback import traceback
traceback.print_exc() 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. 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...") logger.info("Caching recent albums for discover page...")
# Clear existing cache # Clear existing cache for this profile
self.database.clear_discovery_recent_albums() self.database.clear_discovery_recent_albums(profile_id=profile_id)
# 30-day window for recent releases # 30-day window for recent releases
cutoff_date = datetime.now() - timedelta(days=30) cutoff_date = datetime.now() - timedelta(days=30)
@ -2106,9 +2110,9 @@ class WatchlistScanner:
from core.itunes_client import iTunesClient from core.itunes_client import iTunesClient
itunes_client = iTunesClient() itunes_client = iTunesClient()
# Get artists to check # Get artists to check (scoped to profile)
watchlist_artists = self.database.get_watchlist_artists() watchlist_artists = self.database.get_watchlist_artists(profile_id=profile_id)
similar_artists = self.database.get_top_similar_artists(limit=50) 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"Checking albums from {len(watchlist_artists)} watchlist + {len(similar_artists)} similar artists")
logger.info(f"Sources: Spotify={spotify_available}, iTunes=True") logger.info(f"Sources: Spotify={spotify_available}, iTunes=True")
@ -2141,7 +2145,7 @@ class WatchlistScanner:
'release_date': release_str[:10], 'release_date': release_str[:10],
'album_type': album.album_type if hasattr(album, 'album_type') else 'album' '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 cached_count[source] += 1
logger.debug(f"Cached [{source}] recent album: {album.name} by {artist_name} ({release_str})") logger.debug(f"Cached [{source}] recent album: {album.name} by {artist_name} ({release_str})")
return True return True
@ -2256,7 +2260,7 @@ class WatchlistScanner:
import traceback import traceback
traceback.print_exc() 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. 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}...") logger.info(f"Curating Release Radar for {source}...")
# 1. Curate Release Radar - 50 tracks from recent albums # 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 = [] release_radar_tracks = []
if not recent_albums: if not recent_albums:
@ -2405,18 +2409,18 @@ class WatchlistScanner:
formatted_track['itunes_track_id'] = track_data['id'] formatted_track['itunes_track_id'] = track_data['id']
formatted_track['itunes_album_id'] = track_data['album'].get('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: except Exception as e:
continue continue
# Save with source suffix for multi-source support # Save with source suffix for multi-source support
playlist_key = f'release_radar_{source}' 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") logger.info(f"Release Radar ({source}) curated: {len(release_radar_tracks)} tracks")
# 2. Curate Discovery Weekly - 50 tracks from discovery pool # 2. Curate Discovery Weekly - 50 tracks from discovery pool
logger.info(f"Curating Discovery Weekly for {source}...") 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: if not discovery_tracks:
logger.warning(f"[{source.upper()}] No discovery pool tracks found for Discovery Weekly - check populate_discovery_pool()") 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) discovery_weekly_tracks.append(track.itunes_track_id)
playlist_key = f'discovery_weekly_{source}' 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") logger.info(f"Discovery Weekly ({source}) curated: {len(discovery_weekly_tracks)} tracks")
# Also save without suffix for backward compatibility (use active source) # Also save without suffix for backward compatibility (use active source)
@ -2467,10 +2471,10 @@ class WatchlistScanner:
discovery_weekly_key = f'discovery_weekly_{active_source}' discovery_weekly_key = f'discovery_weekly_{active_source}'
# Copy active source playlists to non-suffixed keys # Copy active source playlists to non-suffixed keys
release_radar_ids = self.database.get_curated_playlist(release_radar_key) or [] 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) 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) self.database.save_curated_playlist('release_radar', release_radar_ids, profile_id=profile_id)
self.database.save_curated_playlist('discovery_weekly', discovery_weekly_ids) self.database.save_curated_playlist('discovery_weekly', discovery_weekly_ids, profile_id=profile_id)
logger.info("Playlist curation complete") logger.info("Playlist curation complete")

View file

@ -25,8 +25,8 @@ class WishlistService:
self._database = get_database(self.database_path) self._database = get_database(self.database_path)
return self._database return self._database
def add_failed_track_from_modal(self, track_info: Dict[str, Any], source_type: str = "unknown", def add_failed_track_from_modal(self, track_info: Dict[str, Any], source_type: str = "unknown",
source_context: Dict[str, Any] = None) -> bool: source_context: Dict[str, Any] = None, profile_id: int = 1) -> bool:
""" """
Add a failed track from a download modal to the wishlist. Add a failed track from a download modal to the wishlist.
@ -74,7 +74,8 @@ class WishlistService:
spotify_track_data=spotify_track, spotify_track_data=spotify_track,
failure_reason=failure_reason, failure_reason=failure_reason,
source_type=source_type, source_type=source_type,
source_info=source_info source_info=source_info,
profile_id=profile_id
) )
except Exception as e: except Exception as e:
@ -82,30 +83,33 @@ class WishlistService:
return False return False
def add_spotify_track_to_wishlist(self, spotify_track_data: Dict[str, Any], failure_reason: str, 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. Directly add a Spotify track to the wishlist.
Args: Args:
spotify_track_data: Full Spotify track data dictionary spotify_track_data: Full Spotify track data dictionary
failure_reason: Reason for the failure failure_reason: Reason for the failure
source_type: Source type ('playlist', 'album', 'manual') source_type: Source type ('playlist', 'album', 'manual')
source_context: Additional context information source_context: Additional context information
profile_id: Profile to add to
""" """
return self.database.add_to_wishlist( return self.database.add_to_wishlist(
spotify_track_data=spotify_track_data, spotify_track_data=spotify_track_data,
failure_reason=failure_reason, failure_reason=failure_reason,
source_type=source_type, 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. Get wishlist tracks formatted for the download modal.
Returns tracks in a format similar to playlist tracks for compatibility. Returns tracks in a format similar to playlist tracks for compatibility.
""" """
try: 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 = [] formatted_tracks = []
for wishlist_track in wishlist_tracks: for wishlist_track in wishlist_tracks:
@ -144,28 +148,29 @@ class WishlistService:
logger.error(f"Error getting wishlist tracks for download: {e}") logger.error(f"Error getting wishlist tracks for download: {e}")
return [] 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. Mark the result of a download attempt for a wishlist track.
Args: Args:
spotify_track_id: Spotify track ID spotify_track_id: Spotify track ID
success: Whether the download was successful success: Whether the download was successful
error_message: Error message if failed 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)""" """Remove a track from the wishlist (typically after successful download)"""
return self.database.remove_from_wishlist(spotify_track_id) return self.database.remove_from_wishlist(spotify_track_id, profile_id=profile_id)
def get_wishlist_count(self) -> int: def get_wishlist_count(self, profile_id: int = 1) -> int:
"""Get the total number of tracks in the wishlist""" """Get the total number of tracks in the wishlist"""
return self.database.get_wishlist_count() return self.database.get_wishlist_count(profile_id=profile_id)
def clear_wishlist(self) -> bool: def clear_wishlist(self, profile_id: int = 1) -> bool:
"""Clear all tracks from the wishlist""" """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: def check_track_in_wishlist(self, spotify_track_id: str) -> bool:
"""Check if a track exists in the wishlist by Spotify track ID""" """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}") logger.error(f"Error finding matching wishlist track: {e}")
return None 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""" """Get a summary of the wishlist for dashboard display"""
try: try:
total_tracks = self.get_wishlist_count() total_tracks = self.get_wishlist_count(profile_id=profile_id)
if total_tracks == 0: if total_tracks == 0:
return { return {
'total_tracks': 0, 'total_tracks': 0,
'by_source_type': {}, 'by_source_type': {},
'recent_failures': [] 'recent_failures': []
} }
# Get detailed breakdown # 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 # Group by source type
by_source_type = {} by_source_type = {}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,63 @@
</head> </head>
<body> <body>
<!-- Profile Picker Overlay -->
<div id="profile-picker-overlay" class="profile-picker-overlay" style="display: none;">
<div class="profile-picker-container">
<h2 class="profile-picker-title">Who's listening?</h2>
<div id="profile-picker-grid" class="profile-picker-grid"></div>
<div id="profile-picker-actions" class="profile-picker-actions" style="display: none;">
<button id="manage-profiles-btn" class="profile-manage-btn">Manage Profiles</button>
</div>
</div>
<!-- PIN Dialog -->
<div id="profile-pin-dialog" class="profile-pin-dialog" style="display: none;">
<div class="profile-pin-content">
<div id="profile-pin-avatar" class="profile-pin-avatar"></div>
<p id="profile-pin-name" class="profile-pin-name"></p>
<input type="password" id="profile-pin-input" class="profile-pin-input" maxlength="6" placeholder="Enter PIN" autocomplete="off">
<div class="profile-pin-buttons">
<button id="profile-pin-cancel" class="profile-pin-cancel">Cancel</button>
<button id="profile-pin-submit" class="profile-pin-submit">Submit</button>
</div>
<p id="profile-pin-error" class="profile-pin-error" style="display: none;"></p>
</div>
</div>
<!-- Profile Management Panel -->
<div id="profile-manage-panel" class="profile-manage-panel" style="display: none;">
<div class="profile-manage-content">
<div class="profile-manage-header">
<h3>Manage Profiles</h3>
<button id="profile-manage-close" class="profile-manage-close-btn">&times;</button>
</div>
<div id="profile-manage-list" class="profile-manage-list"></div>
<div class="profile-manage-add">
<h4>Add Profile</h4>
<input type="text" id="new-profile-name" class="profile-input" placeholder="Profile name" maxlength="20">
<input type="url" id="new-profile-avatar-url" class="profile-input" placeholder="Avatar image URL (optional)">
<div class="profile-color-picker">
<span class="profile-color-swatch" data-color="#6366f1" style="background:#6366f1" title="Indigo"></span>
<span class="profile-color-swatch" data-color="#ec4899" style="background:#ec4899" title="Pink"></span>
<span class="profile-color-swatch" data-color="#10b981" style="background:#10b981" title="Green"></span>
<span class="profile-color-swatch" data-color="#f59e0b" style="background:#f59e0b" title="Amber"></span>
<span class="profile-color-swatch" data-color="#3b82f6" style="background:#3b82f6" title="Blue"></span>
<span class="profile-color-swatch" data-color="#ef4444" style="background:#ef4444" title="Red"></span>
<span class="profile-color-swatch" data-color="#8b5cf6" style="background:#8b5cf6" title="Purple"></span>
<span class="profile-color-swatch" data-color="#14b8a6" style="background:#14b8a6" title="Teal"></span>
</div>
<input type="password" id="new-profile-pin" class="profile-input" placeholder="PIN (optional)" maxlength="6">
<button id="create-profile-btn" class="profile-create-btn">Create Profile</button>
</div>
<div id="admin-pin-section" class="admin-pin-section" style="display: none;">
<h4>Admin PIN</h4>
<p class="admin-pin-note">Required when multiple profiles exist</p>
<input type="password" id="admin-pin-input" class="profile-input" placeholder="Set admin PIN" maxlength="6">
<button id="set-admin-pin-btn" class="profile-create-btn">Set Admin PIN</button>
</div>
</div>
</div>
</div>
<div class="main-container"> <div class="main-container">
<!-- Mobile Navigation --> <!-- Mobile Navigation -->
<button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle navigation"> <button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle navigation">
@ -26,6 +83,10 @@
<div class="sidebar-header"> <div class="sidebar-header">
<h1 class="app-name">SoulSync</h1> <h1 class="app-name">SoulSync</h1>
<p class="app-subtitle">Music Sync & Manager</p> <p class="app-subtitle">Music Sync & Manager</p>
<div id="profile-indicator" class="profile-indicator" style="display: none;" title="Switch profile">
<div id="profile-indicator-avatar" class="profile-indicator-avatar"></div>
<span id="profile-indicator-name" class="profile-indicator-name"></span>
</div>
</div> </div>
<!-- Navigation Section --> <!-- Navigation Section -->

View file

@ -2151,4 +2151,25 @@
gap: 4px; gap: 4px;
align-items: flex-start; align-items: flex-start;
} }
/* Profile Picker - Mobile */
.profile-picker-grid {
gap: 20px;
}
.profile-avatar {
width: 64px;
height: 64px;
font-size: 26px;
}
.profile-manage-content {
width: calc(100vw - 32px);
max-width: 400px;
}
.profile-pin-content {
width: calc(100vw - 32px);
max-width: 340px;
}
} }

View file

@ -147,6 +147,10 @@ function initializeWebSocket() {
console.log('WebSocket connected'); console.log('WebSocket connected');
socketConnected = true; socketConnected = true;
resubscribeDownloadBatches(); resubscribeDownloadBatches();
// Join profile room for scoped watchlist/wishlist count updates
if (currentProfile) {
socket.emit('profile:join', { profile_id: currentProfile.id });
}
}); });
socket.on('disconnect', (reason) => { socket.on('disconnect', (reason) => {
@ -156,6 +160,10 @@ function initializeWebSocket() {
socket.on('reconnect', (attemptNumber) => { socket.on('reconnect', (attemptNumber) => {
console.log(`WebSocket reconnected after ${attemptNumber} attempts`); console.log(`WebSocket reconnected after ${attemptNumber} attempts`);
// Rejoin profile room for scoped WebSocket emits
if (currentProfile) {
socket.emit('profile:join', { profile_id: currentProfile.id });
}
// Phase 1: Full state refresh on reconnect // Phase 1: Full state refresh on reconnect
fetchAndUpdateServiceStatus(); fetchAndUpdateServiceStatus();
updateWatchlistButtonCount(); updateWatchlistButtonCount();
@ -637,9 +645,571 @@ function initAccentColorListeners() {
if (saved) applyAccentColor(saved); if (saved) applyAccentColor(saved);
})(); })();
document.addEventListener('DOMContentLoaded', function () { // ── Profile System ─────────────────────────────────────────────
let currentProfile = null;
function renderProfileAvatar(el, profile) {
// Renders avatar as image (if avatar_url set) or colored initial fallback
// Preserves existing classes, ensures 'profile-avatar' is present
if (!el.classList.contains('profile-avatar') && !el.classList.contains('profile-indicator-avatar') && !el.classList.contains('profile-pin-avatar')) {
el.className = 'profile-avatar';
}
el.style.background = profile.avatar_color || '#6366f1';
el.textContent = '';
if (profile.avatar_url) {
const img = document.createElement('img');
img.src = profile.avatar_url;
img.alt = profile.name;
img.className = 'profile-avatar-img';
img.onerror = () => {
img.remove();
el.textContent = profile.name.charAt(0).toUpperCase();
};
el.appendChild(img);
} else {
el.textContent = profile.name.charAt(0).toUpperCase();
}
}
async function initProfileSystem() {
try {
// Check if a session already has a profile selected
const currentRes = await fetch('/api/profiles/current');
const currentData = await currentRes.json();
if (currentData.success && currentData.profile) {
currentProfile = currentData.profile;
updateProfileIndicator();
return true; // Profile already selected, skip picker
}
// Fetch all profiles
const res = await fetch('/api/profiles');
const data = await res.json();
const profiles = data.profiles || [];
if (profiles.length === 0) {
// No profiles yet — auto-select admin profile 1
await selectProfile(1);
return true;
}
if (profiles.length === 1) {
// Only one profile — always auto-select (PIN only matters with multiple profiles)
await selectProfile(profiles[0].id);
return true;
}
// Multiple profiles or PIN required — show picker
showProfilePicker(profiles);
return false; // App init deferred until profile selected
} catch (e) {
console.error('Profile init error:', e);
return true; // Fall through to normal init
}
}
function showProfilePicker(profiles, canCancel = false) {
const overlay = document.getElementById('profile-picker-overlay');
const grid = document.getElementById('profile-picker-grid');
const actions = document.getElementById('profile-picker-actions');
grid.innerHTML = '';
profiles.forEach(p => {
const card = document.createElement('div');
card.className = 'profile-picker-card';
const avatarEl = document.createElement('div');
renderProfileAvatar(avatarEl, p);
card.appendChild(avatarEl);
const nameEl = document.createElement('span');
nameEl.className = 'profile-name';
nameEl.textContent = p.name;
card.appendChild(nameEl);
if (p.is_admin) {
const badge = document.createElement('span');
badge.className = 'profile-badge';
badge.textContent = 'Admin';
card.appendChild(badge);
}
card.onclick = () => handleProfileClick(p);
grid.appendChild(card);
});
// Show manage button only if current profile is admin (not on first load before selection)
const isAdmin = currentProfile ? currentProfile.is_admin : false;
if (isAdmin) {
actions.style.display = '';
} else {
actions.style.display = 'none';
}
// Show/remove cancel button when opened from sidebar indicator
let cancelBtn = overlay.querySelector('.profile-picker-cancel');
if (cancelBtn) cancelBtn.remove();
if (canCancel) {
cancelBtn = document.createElement('button');
cancelBtn.className = 'profile-picker-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = () => hideProfilePicker();
actions.parentElement.appendChild(cancelBtn);
}
overlay.style.display = 'flex';
document.querySelector('.main-container').style.display = 'none';
}
async function handleProfileClick(profile) {
// Fetch profile count — PIN only matters with multiple profiles
let profileCount = 1;
try {
const r = await fetch('/api/profiles');
const d = await r.json();
profileCount = (d.profiles || []).length;
} catch (e) {}
if (profile.has_pin && profileCount > 1) {
showPinDialog(profile);
} else {
const wasSwitching = !!currentProfile;
await selectProfile(profile.id);
if (wasSwitching) {
window.location.reload();
return;
}
hideProfilePicker();
initApp();
}
}
function showPinDialog(profile) {
const dialog = document.getElementById('profile-pin-dialog');
const avatar = document.getElementById('profile-pin-avatar');
const nameEl = document.getElementById('profile-pin-name');
const input = document.getElementById('profile-pin-input');
const errorEl = document.getElementById('profile-pin-error');
renderProfileAvatar(avatar, profile);
nameEl.textContent = profile.name;
input.value = '';
errorEl.style.display = 'none';
dialog.style.display = 'flex';
setTimeout(() => input.focus(), 100);
const submit = document.getElementById('profile-pin-submit');
const cancel = document.getElementById('profile-pin-cancel');
const wasSwitching = !!currentProfile;
const handleSubmit = async () => {
const pin = input.value;
if (!pin) return;
try {
const res = await fetch('/api/profiles/select', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profile.id, pin })
});
const data = await res.json();
if (data.success) {
cleanup();
if (wasSwitching) {
window.location.reload();
return;
}
currentProfile = data.profile;
dialog.style.display = 'none';
hideProfilePicker();
updateProfileIndicator();
initApp();
return;
} else {
errorEl.textContent = data.error || 'Invalid PIN';
errorEl.style.display = '';
input.value = '';
input.focus();
}
} catch (e) {
errorEl.textContent = 'Connection error';
errorEl.style.display = '';
}
cleanup();
};
const handleCancel = () => {
dialog.style.display = 'none';
cleanup();
};
const handleKeydown = (e) => {
if (e.key === 'Enter') handleSubmit();
if (e.key === 'Escape') handleCancel();
};
const cleanup = () => {
submit.removeEventListener('click', handleSubmit);
cancel.removeEventListener('click', handleCancel);
input.removeEventListener('keydown', handleKeydown);
};
submit.addEventListener('click', handleSubmit);
cancel.addEventListener('click', handleCancel);
input.addEventListener('keydown', handleKeydown);
}
async function selectProfile(profileId) {
try {
const oldProfileId = currentProfile ? currentProfile.id : null;
const res = await fetch('/api/profiles/select', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profileId })
});
const data = await res.json();
if (data.success) {
currentProfile = data.profile;
updateProfileIndicator();
// Join profile-scoped WebSocket room for watchlist/wishlist count updates
if (socket && socket.connected) {
socket.emit('profile:join', { profile_id: profileId, old_profile_id: oldProfileId });
}
}
return data.success;
} catch (e) {
console.error('Error selecting profile:', e);
return false;
}
}
function hideProfilePicker() {
document.getElementById('profile-picker-overlay').style.display = 'none';
document.querySelector('.main-container').style.display = 'flex';
}
function updateProfileIndicator() {
const indicator = document.getElementById('profile-indicator');
if (!currentProfile || !indicator) return;
const avatar = document.getElementById('profile-indicator-avatar');
const name = document.getElementById('profile-indicator-name');
renderProfileAvatar(avatar, currentProfile);
name.textContent = currentProfile.name;
indicator.style.display = 'flex';
indicator.onclick = async () => {
const res = await fetch('/api/profiles');
const data = await res.json();
if (data.profiles && data.profiles.length > 0) {
showProfilePicker(data.profiles, true);
}
};
// Hide settings nav for non-admin
const settingsBtn = document.querySelector('.nav-button[data-page="settings"]');
if (settingsBtn && !currentProfile.is_admin) {
settingsBtn.style.display = 'none';
} else if (settingsBtn) {
settingsBtn.style.display = '';
}
}
function initProfileManagement() {
const manageBtn = document.getElementById('manage-profiles-btn');
const closeBtn = document.getElementById('profile-manage-close');
const createBtn = document.getElementById('create-profile-btn');
const adminPinBtn = document.getElementById('set-admin-pin-btn');
if (manageBtn) {
manageBtn.onclick = () => {
document.getElementById('profile-manage-panel').style.display = 'flex';
loadProfileManageList();
};
}
if (closeBtn) {
closeBtn.onclick = () => {
document.getElementById('profile-manage-panel').style.display = 'none';
// Refresh picker — keep cancel button if user already has a profile selected
const hasCancel = !!currentProfile;
fetch('/api/profiles').then(r => r.json()).then(d => {
showProfilePicker(d.profiles || [], hasCancel);
});
};
}
// Color picker
let selectedColor = '#6366f1';
document.querySelectorAll('.profile-color-swatch').forEach(swatch => {
swatch.onclick = () => {
document.querySelectorAll('.profile-color-swatch').forEach(s => s.classList.remove('selected'));
swatch.classList.add('selected');
selectedColor = swatch.dataset.color;
};
});
// Select first by default
const firstSwatch = document.querySelector('.profile-color-swatch');
if (firstSwatch) firstSwatch.classList.add('selected');
if (createBtn) {
createBtn.onclick = async () => {
const name = document.getElementById('new-profile-name').value.trim();
const avatarUrl = document.getElementById('new-profile-avatar-url').value.trim();
const pin = document.getElementById('new-profile-pin').value;
if (!name) return;
const res = await fetch('/api/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, avatar_color: selectedColor, avatar_url: avatarUrl || undefined, pin: pin || undefined })
});
const data = await res.json();
if (data.success) {
document.getElementById('new-profile-name').value = '';
document.getElementById('new-profile-avatar-url').value = '';
document.getElementById('new-profile-pin').value = '';
loadProfileManageList();
// Show admin PIN section if >1 profiles and admin has no PIN
checkAdminPinRequired();
} else {
alert(data.error || 'Failed to create profile');
}
};
}
if (adminPinBtn) {
adminPinBtn.onclick = async () => {
const pin = document.getElementById('admin-pin-input').value;
if (!pin || pin.length < 1) return;
// Find admin profile
const res = await fetch('/api/profiles');
const data = await res.json();
const admin = (data.profiles || []).find(p => p.is_admin);
if (!admin) return;
try {
const pinRes = await fetch(`/api/profiles/${admin.id}/set-pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin })
});
const pinData = await pinRes.json();
if (!pinData.success) {
alert(pinData.error || 'Failed to set PIN');
return;
}
} catch (e) {
alert('Connection error');
return;
}
document.getElementById('admin-pin-input').value = '';
document.getElementById('admin-pin-section').style.display = 'none';
loadProfileManageList();
};
}
}
async function loadProfileManageList() {
const list = document.getElementById('profile-manage-list');
const res = await fetch('/api/profiles');
const data = await res.json();
const profiles = data.profiles || [];
list.innerHTML = '';
profiles.forEach(p => {
const item = document.createElement('div');
item.className = 'profile-manage-item';
const av = document.createElement('div');
renderProfileAvatar(av, p);
item.appendChild(av);
const info = document.createElement('div');
info.className = 'profile-info';
const nameDiv = document.createElement('div');
nameDiv.className = 'name';
nameDiv.textContent = p.name + (p.has_pin ? ' 🔒' : '');
info.appendChild(nameDiv);
if (p.is_admin) {
const roleDiv = document.createElement('div');
roleDiv.className = 'role';
roleDiv.textContent = 'Admin';
info.appendChild(roleDiv);
}
item.appendChild(info);
const actions = document.createElement('div');
actions.className = 'profile-manage-actions';
const editBtn = document.createElement('button');
editBtn.className = 'profile-edit-btn';
editBtn.dataset.id = p.id;
editBtn.dataset.name = p.name;
editBtn.dataset.color = p.avatar_color || '#6366f1';
editBtn.dataset.avatarUrl = p.avatar_url || '';
editBtn.title = 'Edit profile';
editBtn.textContent = '✏️';
actions.appendChild(editBtn);
if (!p.is_admin) {
const delBtn = document.createElement('button');
delBtn.className = 'profile-delete-btn';
delBtn.dataset.id = p.id;
delBtn.title = 'Delete profile';
delBtn.textContent = '🗑️';
actions.appendChild(delBtn);
}
item.appendChild(actions);
list.appendChild(item);
});
// Bind edit buttons
list.querySelectorAll('.profile-edit-btn').forEach(btn => {
btn.onclick = () => {
showProfileEditForm(btn.dataset.id, btn.dataset.name, btn.dataset.color, btn.dataset.avatarUrl);
};
});
// Bind delete buttons
list.querySelectorAll('.profile-delete-btn').forEach(btn => {
btn.onclick = async () => {
if (!confirm('Delete this profile and all its data?')) return;
try {
const res = await fetch(`/api/profiles/${btn.dataset.id}`, { method: 'DELETE' });
const data = await res.json();
if (!data.success) {
alert(data.error || 'Failed to delete profile');
}
} catch (e) {
alert('Connection error');
}
loadProfileManageList();
};
});
checkAdminPinRequired();
}
function showProfileEditForm(profileId, currentName, currentColor, currentAvatarUrl) {
const list = document.getElementById('profile-manage-list');
// Remove any existing edit form
const existing = document.getElementById('profile-edit-form');
if (existing) existing.remove();
const editColors = ['#6366f1','#ec4899','#10b981','#f59e0b','#3b82f6','#ef4444','#8b5cf6','#14b8a6'];
const form = document.createElement('div');
form.id = 'profile-edit-form';
form.className = 'profile-edit-form';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'profile-input';
nameInput.value = currentName;
nameInput.maxLength = 20;
nameInput.placeholder = 'Profile name';
form.appendChild(nameInput);
const urlInput = document.createElement('input');
urlInput.type = 'url';
urlInput.className = 'profile-input';
urlInput.value = currentAvatarUrl || '';
urlInput.placeholder = 'Avatar image URL (optional)';
form.appendChild(urlInput);
const colorRow = document.createElement('div');
colorRow.className = 'profile-color-picker';
let editColor = currentColor;
editColors.forEach(c => {
const swatch = document.createElement('span');
swatch.className = 'profile-color-swatch' + (c === currentColor ? ' selected' : '');
swatch.style.background = c;
swatch.dataset.color = c;
swatch.onclick = () => {
colorRow.querySelectorAll('.profile-color-swatch').forEach(s => s.classList.remove('selected'));
swatch.classList.add('selected');
editColor = c;
};
colorRow.appendChild(swatch);
});
form.appendChild(colorRow);
const btnRow = document.createElement('div');
btnRow.className = 'profile-edit-buttons';
const saveBtn = document.createElement('button');
saveBtn.className = 'profile-create-btn';
saveBtn.textContent = 'Save';
saveBtn.onclick = async () => {
const newName = nameInput.value.trim();
if (!newName) { alert('Name cannot be empty'); return; }
const newAvatarUrl = urlInput.value.trim() || null;
try {
const res = await fetch(`/api/profiles/${profileId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, avatar_color: editColor, avatar_url: newAvatarUrl })
});
const data = await res.json();
if (data.success) {
// Update sidebar indicator if editing current profile
if (currentProfile && currentProfile.id == profileId) {
currentProfile.name = newName;
currentProfile.avatar_color = editColor;
currentProfile.avatar_url = newAvatarUrl;
updateProfileIndicator();
}
loadProfileManageList();
} else {
alert(data.error || 'Failed to update profile');
}
} catch (e) {
alert('Connection error');
}
};
btnRow.appendChild(saveBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'profile-picker-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = () => form.remove();
btnRow.appendChild(cancelBtn);
form.appendChild(btnRow);
list.appendChild(form);
nameInput.focus();
nameInput.select();
}
async function checkAdminPinRequired() {
const res = await fetch('/api/profiles');
const data = await res.json();
const profiles = data.profiles || [];
const admin = profiles.find(p => p.is_admin);
const section = document.getElementById('admin-pin-section');
if (profiles.length > 1 && admin && !admin.has_pin && section) {
section.style.display = '';
} else if (section) {
section.style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', async function () {
console.log('SoulSync WebUI initializing...'); console.log('SoulSync WebUI initializing...');
// Initialize profile management UI handlers
initProfileManagement();
// Check profiles first — may show picker instead of app
const profileReady = await initProfileSystem();
if (!profileReady) {
console.log('Waiting for profile selection...');
return; // App init deferred until profile is selected via picker
}
initApp();
});
function initApp() {
// Initialize components // Initialize components
initializeNavigation(); initializeNavigation();
initializeMobileNavigation(); initializeMobileNavigation();
@ -702,7 +1272,7 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
console.log('SoulSync WebUI initialized successfully!'); console.log('SoulSync WebUI initialized successfully!');
}); }
// =============================== // ===============================
// NAVIGATION SYSTEM // NAVIGATION SYSTEM

View file

@ -94,7 +94,7 @@ body {
/* Sidebar Header */ /* Sidebar Header */
.sidebar-header { .sidebar-header {
height: 95px; min-height: 115px;
background: linear-gradient(180deg, background: linear-gradient(180deg,
rgba(var(--accent-rgb), 0.14) 0%, rgba(var(--accent-rgb), 0.14) 0%,
rgba(var(--accent-rgb), 0.08) 30%, rgba(var(--accent-rgb), 0.08) 30%,
@ -102,11 +102,11 @@ body {
transparent 100%); transparent 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-top-right-radius: 20px; border-top-right-radius: 20px;
padding: 28px 24px; padding: 20px 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
gap: 6px; gap: 8px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@ -27250,4 +27250,477 @@ body {
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
}
/* ── Profile Picker ─────────────────────────────────────────────── */
.profile-picker-overlay {
position: fixed;
inset: 0;
z-index: 99999;
background: linear-gradient(135deg, #0a0a1a 0%, #111827 50%, #0a0a1a 100%);
display: flex;
align-items: center;
justify-content: center;
animation: profileFadeIn 0.4s ease;
}
@keyframes profileFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.profile-picker-container {
text-align: center;
max-width: 700px;
width: 100%;
padding: 40px 20px;
}
.profile-picker-title {
font-size: 28px;
font-weight: 600;
color: #e5e7eb;
margin-bottom: 40px;
letter-spacing: -0.02em;
}
.profile-picker-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 32px;
margin-bottom: 40px;
}
.profile-picker-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
transition: transform 0.2s ease;
min-width: 100px;
}
.profile-picker-card:hover {
transform: scale(1.08);
}
.profile-picker-card:hover .profile-avatar {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.3);
}
.profile-avatar {
width: 80px;
height: 80px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 700;
color: #fff;
transition: box-shadow 0.2s ease;
user-select: none;
overflow: hidden;
position: relative;
}
.profile-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.profile-picker-card .profile-name {
font-size: 14px;
color: #9ca3af;
font-weight: 500;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-picker-card .profile-badge {
font-size: 10px;
color: #6366f1;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.profile-picker-actions {
margin-top: 16px;
}
.profile-manage-btn {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
color: #9ca3af;
padding: 10px 24px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
}
.profile-manage-btn:hover {
border-color: rgba(255, 255, 255, 0.3);
color: #e5e7eb;
}
.profile-picker-cancel {
background: transparent;
border: none;
color: #6b7280;
padding: 10px 24px;
font-size: 13px;
cursor: pointer;
margin-top: 8px;
transition: color 0.2s ease;
}
.profile-picker-cancel:hover {
color: #e5e7eb;
}
/* PIN Dialog */
.profile-pin-dialog {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100000;
}
.profile-pin-content {
background: #1f2937;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 32px;
text-align: center;
min-width: 300px;
}
.profile-pin-avatar {
width: 60px;
height: 60px;
border-radius: 10px;
margin: 0 auto 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
color: #fff;
overflow: hidden;
position: relative;
}
.profile-pin-name {
color: #e5e7eb;
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
}
.profile-pin-input {
width: 160px;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
color: #e5e7eb;
font-size: 18px;
text-align: center;
letter-spacing: 8px;
outline: none;
}
.profile-pin-input:focus {
border-color: #6366f1;
}
.profile-pin-buttons {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 20px;
}
.profile-pin-cancel, .profile-pin-submit {
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
border: none;
}
.profile-pin-cancel {
background: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
.profile-pin-submit {
background: #6366f1;
color: #fff;
}
.profile-pin-error {
color: #ef4444;
font-size: 13px;
margin-top: 12px;
}
/* Profile Management Panel */
.profile-manage-panel {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100000;
}
.profile-manage-content {
background: #1f2937;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 28px;
width: 420px;
max-height: 80vh;
overflow-y: auto;
}
.profile-manage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.profile-manage-header h3 {
color: #e5e7eb;
font-size: 18px;
margin: 0;
}
.profile-manage-close-btn {
background: none;
border: none;
color: #9ca3af;
font-size: 24px;
cursor: pointer;
}
.profile-manage-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 24px;
}
.profile-manage-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
.profile-manage-item .profile-avatar {
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 16px;
}
.profile-manage-item .profile-info {
flex: 1;
}
.profile-manage-item .profile-info .name {
color: #e5e7eb;
font-size: 14px;
font-weight: 500;
}
.profile-manage-item .profile-info .role {
color: #6366f1;
font-size: 11px;
text-transform: uppercase;
}
.profile-manage-actions {
display: flex;
gap: 4px;
align-items: center;
}
.profile-manage-item .profile-edit-btn,
.profile-manage-item .profile-delete-btn {
background: none;
border: none;
color: #6b7280;
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.profile-manage-item .profile-edit-btn:hover {
color: #6366f1;
background: rgba(99, 102, 241, 0.1);
}
.profile-manage-item .profile-delete-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.profile-edit-form {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
background: rgba(99, 102, 241, 0.05);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 8px;
margin-top: 4px;
}
.profile-edit-buttons {
display: flex;
gap: 8px;
}
.profile-edit-buttons .profile-create-btn {
flex: 1;
}
.profile-edit-buttons .profile-picker-cancel {
flex: 1;
}
.profile-manage-add h4, .admin-pin-section h4 {
color: #e5e7eb;
font-size: 14px;
margin: 0 0 12px;
}
.profile-input {
width: 100%;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: #e5e7eb;
font-size: 14px;
margin-bottom: 10px;
outline: none;
box-sizing: border-box;
}
.profile-input:focus {
border-color: #6366f1;
}
.profile-color-picker {
display: flex;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.profile-color-swatch {
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition: border-color 0.2s, transform 0.15s;
}
.profile-color-swatch:hover {
transform: scale(1.15);
}
.profile-color-swatch.selected {
border-color: #fff;
}
.profile-create-btn {
width: 100%;
padding: 10px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.profile-create-btn:hover {
background: #4f46e5;
}
.admin-pin-section {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.admin-pin-note {
color: #9ca3af;
font-size: 12px;
margin: 0 0 10px;
}
/* Profile Indicator in Sidebar */
.profile-indicator {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
margin-top: 4px;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.2s;
}
.profile-indicator:hover {
background: rgba(255, 255, 255, 0.08);
}
.profile-indicator-avatar {
width: 22px;
height: 22px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
overflow: hidden;
position: relative;
}
.profile-indicator-name {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }