diff --git a/README.md b/README.md index 0d728ab5..77b4ba83 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ Before configuring SoulSync, you'll need to obtain API credentials from Spotify 4. Fill in the required information: - **App name**: "SoulSync" (or any name you prefer) - **App description**: "Music library sync application" - - **Redirect URI**: `http://localhost:8888/callback` (or leave blank) + - **Redirect URI**: `http://127.0.0.1:8888/callback` - Check the boxes to agree to the Terms of Service 5. Click "Save" diff --git a/config/config.json b/config/config.json index 307ee96c..47c0a263 100644 --- a/config/config.json +++ b/config/config.json @@ -1,24 +1,28 @@ { "active_media_server": "plex", "spotify": { - "client_id": "CLIENT ID", - "client_secret": "CLIENT SECRET" + "client_id": "SpotifyClientID", + "client_secret": "SpotifyClientSecret" + }, + "tidal": { + "client_id": "TidalClientID", + "client_secret": "TidalClientSecret" }, "plex": { "base_url": "http://192.168.86.36:32400", - "token": "PLEX TOKEN", + "token": "PLEX_API_TOKEN", "auto_detect": true }, "jellyfin": { "base_url": "http://localhost:8096", - "api_key": "JELLYFIN API KEY", + "api_key": "JELLYFIN_API_KEY", "auto_detect": true }, "soulseek": { "slskd_url": "http://localhost:5030", - "api_key": "SLSKD API KEY", - "download_path": "./downloads", - "transfer_path": "./transfers" + "api_key": "SoulseekAPIKey", + "download_path": "./Downloads", + "transfer_path": "./Transfer" }, "logging": { "path": "logs/app.log", diff --git a/core/tidal_client.py b/core/tidal_client.py new file mode 100644 index 00000000..ade893b4 --- /dev/null +++ b/core/tidal_client.py @@ -0,0 +1,783 @@ +import requests +import time +import threading +from typing import Dict, List, Optional, Any +from functools import wraps +from dataclasses import dataclass +from utils.logging_config import get_logger +from config.settings import config_manager +import json +import base64 +import webbrowser +import urllib.parse +from http.server import HTTPServer, BaseHTTPRequestHandler +import socketserver +import hashlib +import secrets + +logger = get_logger("tidal_client") + +# Global rate limiting variables +_last_api_call_time = 0 +_api_call_lock = threading.Lock() +MIN_API_INTERVAL = 0.2 # 200ms between API calls + +def rate_limited(func): + """Decorator to enforce rate limiting on Tidal API calls""" + @wraps(func) + def wrapper(*args, **kwargs): + global _last_api_call_time + + with _api_call_lock: + current_time = time.time() + time_since_last_call = current_time - _last_api_call_time + + if time_since_last_call < MIN_API_INTERVAL: + sleep_time = MIN_API_INTERVAL - time_since_last_call + time.sleep(sleep_time) + + _last_api_call_time = time.time() + + try: + result = func(*args, **kwargs) + return result + except Exception as e: + # Implement exponential backoff for API errors + if "rate limit" in str(e).lower() or "429" in str(e): + logger.warning(f"Rate limit hit, implementing backoff: {e}") + time.sleep(3.0) # Wait 3 seconds before retrying + elif "503" in str(e) or "502" in str(e): + logger.warning(f"Tidal service error, backing off: {e}") + time.sleep(2.0) # Wait 2 seconds for service errors + raise + return wrapper + +@dataclass +class Track: + """Tidal track data structure compatible with existing Track objects""" + id: str + name: str + artists: List[str] + album: str = "" + duration_ms: int = 0 + external_urls: Dict[str, str] = None + popularity: int = 0 + explicit: bool = False + + def __post_init__(self): + if self.external_urls is None: + self.external_urls = {} + +@dataclass +class Playlist: + """Tidal playlist data structure compatible with existing Playlist objects""" + id: str + name: str + description: str = "" + tracks: List[Track] = None + external_urls: Dict[str, str] = None + owner: Optional[Dict[str, Any]] = None + public: bool = True + + def __post_init__(self): + if self.tracks is None: + self.tracks = [] + if self.external_urls is None: + self.external_urls = {} + +class TidalClient: + """Tidal API client for fetching user playlists and track data""" + + def __init__(self): + self.client_id = None + self.client_secret = None + self.access_token = None + self.refresh_token = None + self.token_expires_at = 0 + self.base_url = "https://openapi.tidal.com/v2" + self.alt_base_url = "https://api.tidal.com/v1" # Alternative API base + self.auth_url = "https://login.tidal.com/authorize" + self.token_url = "https://auth.tidal.com/v1/oauth2/token" + self.redirect_uri = "http://127.0.0.1:8889/tidal/callback" + self.session = requests.Session() + self.auth_server = None + self.auth_code = None + self.code_verifier = None + self.code_challenge = None + + self._load_config() + self._setup_session() + + # Try to load saved tokens + self._load_saved_tokens() + + def _load_config(self): + """Load Tidal configuration from settings""" + try: + tidal_config = config_manager.get('tidal', {}) + self.client_id = tidal_config.get('client_id') + self.client_secret = tidal_config.get('client_secret') + + if not self.client_id or not self.client_secret: + logger.warning("Tidal client ID or secret not configured") + return False + + logger.info(f"Loaded Tidal config with client ID: {self.client_id[:8]}...") + return True + except Exception as e: + logger.error(f"Failed to load Tidal configuration: {e}") + return False + + def _setup_session(self): + """Setup requests session with headers""" + self.session.headers.update({ + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'SoulSync/1.0' + }) + + def _load_saved_tokens(self): + """Load saved tokens from config""" + try: + tidal_tokens = config_manager.get('tidal_tokens', {}) + self.access_token = tidal_tokens.get('access_token') + self.refresh_token = tidal_tokens.get('refresh_token') + self.token_expires_at = tidal_tokens.get('expires_at', 0) + + if self.access_token: + self.session.headers['Authorization'] = f'Bearer {self.access_token}' + logger.info("Loaded saved Tidal tokens") + except Exception as e: + logger.error(f"Error loading saved Tidal tokens: {e}") + + def _save_tokens(self): + """Save tokens to config""" + try: + tidal_tokens = { + 'access_token': self.access_token, + 'refresh_token': self.refresh_token, + 'expires_at': self.token_expires_at + } + config_manager.set('tidal_tokens', tidal_tokens) + logger.info("Saved Tidal tokens") + except Exception as e: + logger.error(f"Error saving Tidal tokens: {e}") + + def _parse_json_api_track(self, track_data: Dict[str, Any], artist_details_map: Dict[str, Any] = None) -> Optional[Track]: + """Parse a track from a JSON:API 'included' object with artist details.""" + try: + track_id = track_data.get('id') + if not track_id: + return None + + attributes = track_data.get('attributes', {}) + + # Parse artists from relationships and artist details map + artists = [] + if artist_details_map: + relationships = track_data.get('relationships', {}) + artist_relationships = relationships.get('artists', {}).get('data', []) + + for artist_ref in artist_relationships: + artist_id = artist_ref.get('id') + if artist_id and artist_id in artist_details_map: + artist_data = artist_details_map[artist_id] + artist_attributes = artist_data.get('attributes', {}) + artist_name = artist_attributes.get('name', 'Unknown Artist') + artists.append(artist_name) + + # Fallback if no artists found + if not artists: + artists = ['Unknown Artist'] + + return Track( + id=str(track_id), + name=attributes.get('title', 'Unknown Track'), + artists=artists, + duration_ms=attributes.get('duration', 0) * 1000 if attributes.get('duration') else 0, # Convert to ms + external_urls={'tidal': f"https://tidal.com/browse/track/{track_id}"}, + explicit=attributes.get('explicit', False) + ) + except Exception as e: + logger.error(f"Error parsing JSON:API track data: {e}") + return None + + + def _generate_pkce_challenge(self): + """Generate PKCE code verifier and challenge""" + # Generate a random code verifier (43-128 characters) + self.code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=') + + # Create code challenge (SHA256 hash of verifier, base64 URL-encoded) + challenge_bytes = hashlib.sha256(self.code_verifier.encode('utf-8')).digest() + self.code_challenge = base64.urlsafe_b64encode(challenge_bytes).decode('utf-8').rstrip('=') + + logger.info(f"Generated PKCE verifier: {self.code_verifier[:10]}...") + logger.info(f"Generated PKCE challenge: {self.code_challenge[:10]}...") + + def authenticate(self): + """Start OAuth authentication flow""" + try: + if not self.client_id: + logger.error("Tidal client ID not configured") + return False + + # Generate PKCE challenge + self._generate_pkce_challenge() + + # Create OAuth URL with PKCE + params = { + 'response_type': 'code', + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'scope': 'user.read playlists.read', # Updated with the required scope + 'code_challenge': self.code_challenge, + 'code_challenge_method': 'S256' + } + + auth_url = f"{self.auth_url}?" + urllib.parse.urlencode(params) + + logger.info("Starting Tidal OAuth flow...") + logger.info(f"OAuth URL: {auth_url}") + logger.info(f"Redirect URI: {self.redirect_uri}") + + # Start callback server + self._start_callback_server() + + # Open browser + webbrowser.open(auth_url) + + # Wait for callback (with timeout) + timeout = 120 # 2 minutes + start_time = time.time() + + while not self.auth_code and time.time() - start_time < timeout: + time.sleep(0.1) + + # Stop server + if self.auth_server: + self.auth_server.shutdown() + self.auth_server = None + + if not self.auth_code: + logger.error("Tidal OAuth timeout - no authorization code received") + return False + + # Exchange code for tokens + return self._exchange_code_for_tokens() + + except Exception as e: + logger.error(f"Error in Tidal OAuth flow: {e}") + return False + + def _start_callback_server(self): + """Start HTTP server to receive OAuth callback""" + # Store reference to self for the callback handler + tidal_client_ref = self + + class CallbackHandler(BaseHTTPRequestHandler): + def do_GET(handler_self): + parsed_url = urllib.parse.urlparse(handler_self.path) + query_params = urllib.parse.parse_qs(parsed_url.query) + + # Debug: Log the full callback URL and parameters + logger.info(f"Tidal callback received: {handler_self.path}") + logger.info(f"Query parameters: {query_params}") + + if 'code' in query_params: + tidal_client_ref.auth_code = query_params['code'][0] + logger.info(f"Received Tidal authorization code: {tidal_client_ref.auth_code[:10]}...") + + # Send success response + handler_self.send_response(200) + handler_self.send_header('Content-type', 'text/html') + handler_self.end_headers() + handler_self.wfile.write(b'

Success!

You can close this window and return to SoulSync.

') + elif 'error' in query_params: + # Handle OAuth errors + error = query_params.get('error', ['unknown'])[0] + error_description = query_params.get('error_description', ['No description'])[0] + logger.error(f"Tidal OAuth error: {error} - {error_description}") + + handler_self.send_response(400) + handler_self.send_header('Content-type', 'text/html') + handler_self.end_headers() + handler_self.wfile.write(f'

OAuth Error

Error: {error}

Description: {error_description}

'.encode()) + else: + logger.error("No authorization code or error in Tidal callback") + handler_self.send_response(400) + handler_self.send_header('Content-type', 'text/html') + handler_self.end_headers() + handler_self.wfile.write(b'

Error

Authorization failed - no code received.

') + + def log_message(handler_self, format, *args): + pass # Suppress server logs + + try: + port = 8889 + self.auth_server = HTTPServer(('localhost', port), CallbackHandler) + server_thread = threading.Thread(target=self.auth_server.serve_forever) + server_thread.daemon = True + server_thread.start() + logger.info(f"Started Tidal callback server on port {port}") + except Exception as e: + logger.error(f"Failed to start Tidal callback server: {e}") + + @rate_limited + def _exchange_code_for_tokens(self): + """Exchange authorization code for access tokens""" + try: + data = { + 'grant_type': 'authorization_code', + 'code': self.auth_code, + 'redirect_uri': self.redirect_uri, + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code_verifier': self.code_verifier + } + + response = self.session.post( + self.token_url, + data=data, + timeout=10 + ) + + if response.status_code == 200: + token_data = response.json() + self.access_token = token_data.get('access_token') + self.refresh_token = token_data.get('refresh_token') + expires_in = token_data.get('expires_in', 3600) + self.token_expires_at = time.time() + expires_in - 60 + + # Update session headers + self.session.headers['Authorization'] = f'Bearer {self.access_token}' + + # Save tokens + self._save_tokens() + + logger.info("Successfully exchanged Tidal code for tokens") + return True + else: + logger.error(f"Failed to exchange Tidal code: {response.status_code} - {response.text}") + return False + + except Exception as e: + logger.error(f"Error exchanging Tidal code for tokens: {e}") + return False + + @rate_limited + def _refresh_access_token(self): + """Refresh the access token using refresh token""" + try: + if not self.refresh_token: + logger.error("No Tidal refresh token available") + return False + + data = { + 'grant_type': 'refresh_token', + 'refresh_token': self.refresh_token, + 'client_id': self.client_id, + 'client_secret': self.client_secret + } + + response = self.session.post( + self.token_url, + data=data, + timeout=10 + ) + + if response.status_code == 200: + token_data = response.json() + self.access_token = token_data.get('access_token') + expires_in = token_data.get('expires_in', 3600) + self.token_expires_at = time.time() + expires_in - 60 + + # Update refresh token if provided + if 'refresh_token' in token_data: + self.refresh_token = token_data['refresh_token'] + + # Update session headers + self.session.headers['Authorization'] = f'Bearer {self.access_token}' + + # Save tokens + self._save_tokens() + + logger.info("Successfully refreshed Tidal access token") + return True + else: + logger.error(f"Failed to refresh Tidal token: {response.status_code} - {response.text}") + return False + + except Exception as e: + logger.error(f"Error refreshing Tidal token: {e}") + return False + + def _ensure_valid_token(self): + """Ensure we have a valid access token""" + if not self.access_token: + logger.info("No Tidal access token - need to authenticate") + return self.authenticate() + + if time.time() >= self.token_expires_at: + logger.info("Tidal access token expired - refreshing...") + if self.refresh_token: + return self._refresh_access_token() + else: + logger.info("No refresh token - need to re-authenticate") + return self.authenticate() + + return True + + def is_authenticated(self): + """Check if client is authenticated""" + # Don't trigger authentication automatically here, just check token status + return (self.access_token is not None and + time.time() < self.token_expires_at) + + def _get_user_id(self): + """Get current user's ID from /users/me endpoint""" + try: + endpoints_to_try = [ + # V2 API (Prioritize this as it matches your documentation) + (f"{self.base_url}/users/me", "v2"), + (f"{self.base_url}/me", "v2 alt"), + # V1 API + (f"{self.alt_base_url}/users/me", "v1") + ] + + for endpoint, version in endpoints_to_try: + try: + logger.info(f"Trying to get user ID from {version}: {endpoint}") + + if version == "v1": + headers = { + 'Accept': 'application/json', + 'Authorization': f'Bearer {self.access_token}', + 'User-Agent': 'TIDAL_ANDROID/2.47.1 okhttp/4.9.0' + } + params = {'countryCode': 'US'} + else: + # For v2, use the standard session headers + headers = self.session.headers.copy() + # The v2 endpoint also requires the correct 'accept' header + headers['accept'] = 'application/vnd.api+json' + params = {} + + response = requests.get(endpoint, headers=headers, params=params, timeout=10) + logger.info(f"User ID response: {response.status_code}") + + if response.status_code == 200: + data = response.json() + logger.info(f"User data keys: {list(data.keys()) if isinstance(data, dict) else 'Not a dict'}") + + # --- START OF CORRECTION --- + # Correctly parse the nested JSON structure from your example + user_id = None + if 'data' in data and isinstance(data['data'], dict): + user_id = data['data'].get('id') + + # Fallback to the original checks for other API responses + if not user_id: + user_id = data.get('id') or data.get('userId') or data.get('uid') or data.get('user_id') + # --- END OF CORRECTION --- + + if user_id: + logger.info(f"Found user ID: {user_id}") + return str(user_id), version + else: + logger.warning(f"No user ID found in response: {data}") + else: + logger.warning(f"Failed to get user ID: {response.status_code} - {response.text[:200]}") + + except Exception as e: + logger.warning(f"Error getting user ID from {version}: {e}") + continue + + return None, None + + except Exception as e: + logger.error(f"Error in _get_user_id: {e}") + return None, None + + @rate_limited + def get_user_playlists_metadata_only(self): + """Get user's playlists using the V2 filtered endpoint.""" + try: + if not self._ensure_valid_token(): + logger.error("Not authenticated with Tidal") + return [] + + # Step 1: Get the user ID, which is needed for the filter. + user_id, _ = self._get_user_id() + if not user_id: + logger.error("Could not retrieve Tidal User ID to fetch playlists.") + return [] + + logger.info(f"Using V2 endpoint to fetch playlists for user ID: {user_id}") + + # Step 2: Construct the correct V2 endpoint and parameters. + endpoint = f"{self.base_url}/playlists" + params = { + 'countryCode': 'US', + 'include': 'items,items.artists', # Include both track data AND artist data + 'filter[r.owners.id]': user_id + } + + headers = self.session.headers.copy() + headers['accept'] = 'application/vnd.api+json' + + response = requests.get(endpoint, params=params, headers=headers, timeout=15) + + if response.status_code != 200: + logger.error(f"Failed to fetch V2 playlists: {response.status_code} - {response.text}") + return [] + + data = response.json() + playlists = [] + + # Step 3: Create lookup maps from the 'included' data for efficient access. + included_data = data.get('included', []) + track_details_map = {item['id']: item for item in included_data if item['type'] == 'tracks'} + artist_details_map = {item['id']: item for item in included_data if item['type'] == 'artists'} + + # Step 4: Process the playlists from the main 'data' array. + for playlist_data in data.get('data', []): + attributes = playlist_data.get('attributes', {}) + playlist_id = playlist_data.get('id') + + new_playlist = Playlist( + id=str(playlist_id), + name=attributes.get('name', 'Unknown Playlist'), + description=attributes.get('description', ''), + external_urls={'tidal': f"https://listen.tidal.com/playlist/{playlist_id}"}, + public=attributes.get('accessType') == 'PUBLIC' + ) + + # Step 5: Match track stubs to the full details from the lookup map. + track_stubs = playlist_data.get('relationships', {}).get('items', {}).get('data', []) + for stub in track_stubs: + track_id = stub.get('id') + full_track_data = track_details_map.get(track_id) + + if full_track_data: + track = self._parse_json_api_track(full_track_data, artist_details_map) + if track: + new_playlist.tracks.append(track) + + playlists.append(new_playlist) + + logger.info(f"Successfully retrieved {len(playlists)} playlists with the V2 filter method.") + return playlists + + except Exception as e: + logger.error(f"A critical error occurred while fetching Tidal V2 playlists: {e}") + return [] + + def _try_direct_playlist_endpoints(self): + """Fallback method to try direct playlist endpoints without user ID""" + playlists = [] + fallback_endpoints = [ + (f"{self.alt_base_url}/my/playlists", "v1 fallback my playlists"), + (f"{self.base_url}/me/playlists", "v2 fallback me playlists"), + ] + + for endpoint, description in fallback_endpoints: + try: + logger.info(f"Fallback: trying {description}") + headers = { + 'Accept': 'application/json', + 'Authorization': f'Bearer {self.access_token}', + 'User-Agent': 'TIDAL_ANDROID/2.47.1 okhttp/4.9.0' + } if "v1" in description else self.session.headers.copy() + + response = requests.get(endpoint, headers=headers, params={'limit': 50}, timeout=10) + logger.info(f"Fallback response: {response.status_code}") + + if response.status_code == 200: + data = response.json() + # Process response same as above + items = data.get('items', data.get('data', data if isinstance(data, list) else [])) + if items: + for item in items: + playlist = Playlist( + id=item.get('id', item.get('uuid', 'unknown')), + name=item.get('title', item.get('name', 'Unknown Playlist')), + description=item.get('description', ''), + external_urls={'tidal': f"https://tidal.com/browse/playlist/{item.get('uuid', item.get('id'))}"}, + public=not item.get('publicPlaylist', True) + ) + playlists.append(playlist) + logger.info(f"Fallback retrieved {len(playlists)} playlists") + return playlists + except Exception as e: + logger.warning(f"Fallback error: {e}") + continue + + logger.error("All Tidal playlist endpoints failed") + return playlists + + + + @rate_limited + def search_tracks(self, query: str, limit: int = 10) -> List[Track]: + """Search for tracks using Tidal's search API""" + try: + if not self._ensure_valid_token(): + logger.error("Not authenticated with Tidal") + return [] + + params = { + 'query': query, + 'type': 'tracks', + 'limit': limit, + 'countryCode': 'US' # Default to US + } + + response = self.session.get( + f"{self.base_url}/searchresults", + params=params, + timeout=10 + ) + + if response.status_code == 200: + data = response.json() + tracks = [] + + if 'tracks' in data and 'items' in data['tracks']: + for item in data['tracks']['items']: + track = self._parse_track_data(item) + if track: + tracks.append(track) + + logger.info(f"Found {len(tracks)} Tidal tracks for query: '{query}'") + return tracks + else: + logger.error(f"Tidal search failed: {response.status_code} - {response.text}") + return [] + + except Exception as e: + logger.error(f"Error searching Tidal tracks: {e}") + return [] + + @rate_limited + def get_playlist(self, playlist_id: str) -> Optional[Playlist]: + """Get playlist details including tracks""" + try: + if not self._ensure_valid_token(): + logger.error("Not authenticated with Tidal") + return None + + # Get playlist metadata + response = self.session.get( + f"{self.base_url}/playlists/{playlist_id}", + params={'countryCode': 'US'}, + timeout=10 + ) + + if response.status_code != 200: + logger.error(f"Failed to get Tidal playlist {playlist_id}: {response.status_code} - {response.text}") + return None + + playlist_data = response.json() + + # Get playlist tracks + tracks_response = self.session.get( + f"{self.base_url}/playlists/{playlist_id}/items", + params={'countryCode': 'US', 'limit': 100}, + timeout=10 + ) + + tracks = [] + if tracks_response.status_code == 200: + tracks_data = tracks_response.json() + if 'items' in tracks_data: + for item in tracks_data['items']: + # Handle different item structures + track_data = item + if 'item' in item and item.get('type') == 'track': + track_data = item['item'] + elif 'resource' in item: + track_data = item['resource'] + + track = self._parse_track_data(track_data) + if track: + tracks.append(track) + else: + logger.warning(f"No tracks found in playlist {playlist_id}") + else: + logger.error(f"Failed to get Tidal playlist tracks: {tracks_response.status_code} - {tracks_response.text}") + + playlist = Playlist( + id=playlist_data.get('id', playlist_id), + name=playlist_data.get('title', 'Unknown Playlist'), + description=playlist_data.get('description', ''), + tracks=tracks, + external_urls={'tidal': f"https://tidal.com/browse/playlist/{playlist_id}"}, + public=not playlist_data.get('publicPlaylist', True) # Tidal uses 'publicPlaylist' field + ) + + logger.info(f"Retrieved Tidal playlist '{playlist.name}' with {len(tracks)} tracks") + return playlist + + except Exception as e: + logger.error(f"Error getting Tidal playlist {playlist_id}: {e}") + return None + + def _parse_track_data(self, item: Dict[str, Any]) -> Optional[Track]: + """Parse Tidal track data into Track object""" + try: + track_id = item.get('id') + if not track_id: + return None + + # Extract artist names + artists = [] + if 'artists' in item: + artists = [artist.get('name', 'Unknown') for artist in item['artists']] + elif 'artist' in item: + artists = [item['artist'].get('name', 'Unknown')] + + track = Track( + id=str(track_id), + name=item.get('title', 'Unknown Track'), + artists=artists, + album=item.get('album', {}).get('title', 'Unknown Album'), + duration_ms=item.get('duration', 0) * 1000, # Convert seconds to ms + external_urls={'tidal': f"https://tidal.com/browse/track/{track_id}"}, + explicit=item.get('explicit', False) + ) + + return track + + except Exception as e: + logger.error(f"Error parsing Tidal track data: {e}") + return None + + def get_user_info(self) -> Optional[Dict[str, Any]]: + """Get current user information""" + try: + if not self._ensure_valid_token(): + logger.error("Not authenticated with Tidal") + return None + + # Note: This would require user OAuth authentication + # For now, return basic info since we're using client credentials + return { + 'display_name': 'Tidal User', + 'id': 'tidal_user', + 'type': 'user' + } + + except Exception as e: + logger.error(f"Error getting Tidal user info: {e}") + return None + +# Global instance +_tidal_client = None + +def get_tidal_client() -> TidalClient: + """Get global Tidal client instance""" + global _tidal_client + if _tidal_client is None: + _tidal_client = TidalClient() + return _tidal_client \ No newline at end of file diff --git a/ui/pages/settings.py b/ui/pages/settings.py index 1dc08be1..3d180bf5 100644 --- a/ui/pages/settings.py +++ b/ui/pages/settings.py @@ -448,6 +448,8 @@ class ServiceTestThread(QThread): try: if self.service_type == "spotify": success, message = self._test_spotify() + elif self.service_type == "tidal": + success, message = self._test_tidal() elif self.service_type == "plex": success, message = self._test_plex() elif self.service_type == "jellyfin": @@ -521,6 +523,55 @@ class ServiceTestThread(QThread): pass return False, f"✗ Spotify test failed:\n{str(e)}" + def _test_tidal(self): + """Test Tidal connection""" + try: + from core.tidal_client import TidalClient + + # Basic validation first + if not self.test_config.get('client_id') or not self.test_config.get('client_secret'): + return False, "✗ Please enter both Client ID and Client Secret" + + # Save temporarily to test + original_client_id = config_manager.get('tidal.client_id') + original_client_secret = config_manager.get('tidal.client_secret') + + config_manager.set('tidal.client_id', self.test_config['client_id']) + config_manager.set('tidal.client_secret', self.test_config['client_secret']) + + # Test connection with timeout protection + try: + client = TidalClient() + + # Test authentication - this will trigger OAuth flow if needed + if client.is_authenticated() or client._ensure_valid_token(): + user_info = client.get_user_info() + username = user_info.get('display_name', 'Tidal User') if user_info else 'Tidal User' + message = f"✓ Tidal connection successful!\nConnected as: {username}\nOAuth flow completed." + success = True + else: + message = "✗ Tidal authentication failed.\nPlease complete the OAuth flow in your browser.\nCheck your credentials and redirect URI." + success = False + + except Exception as client_e: + message = f"✗ Failed to create Tidal client:\n{str(client_e)}" + success = False + + # Restore original values + config_manager.set('tidal.client_id', original_client_id) + config_manager.set('tidal.client_secret', original_client_secret) + + return success, message + + except Exception as e: + # Restore original values even on exception + try: + config_manager.set('tidal.client_id', original_client_id) + config_manager.set('tidal.client_secret', original_client_secret) + except: + pass + return False, f"✗ Tidal test failed:\n{str(e)}" + def _test_plex(self): """Test Plex connection""" try: @@ -959,6 +1010,11 @@ class SettingsPage(QWidget): self.client_id_input.setText(spotify_config.get('client_id', '')) self.client_secret_input.setText(spotify_config.get('client_secret', '')) + # Load Tidal config + tidal_config = config_manager.get('tidal', {}) + self.tidal_client_id_input.setText(tidal_config.get('client_id', '')) + self.tidal_client_secret_input.setText(tidal_config.get('client_secret', '')) + # Load Plex config plex_config = config_manager.get_plex_config() self.plex_url_input.setText(plex_config.get('base_url', '')) @@ -1044,6 +1100,10 @@ class SettingsPage(QWidget): config_manager.set('spotify.client_id', self.client_id_input.text()) config_manager.set('spotify.client_secret', self.client_secret_input.text()) + # Save Tidal settings + config_manager.set('tidal.client_id', self.tidal_client_id_input.text()) + config_manager.set('tidal.client_secret', self.tidal_client_secret_input.text()) + # Save Plex settings config_manager.set('plex.base_url', self.plex_url_input.text()) config_manager.set('plex.token', self.plex_token_input.text()) @@ -1089,6 +1149,8 @@ class SettingsPage(QWidget): # Emit signals for service configuration changes to reinitialize clients self.settings_changed.emit('spotify.client_id', self.client_id_input.text()) self.settings_changed.emit('spotify.client_secret', self.client_secret_input.text()) + self.settings_changed.emit('tidal.client_id', self.tidal_client_id_input.text()) + self.settings_changed.emit('tidal.client_secret', self.tidal_client_secret_input.text()) self.settings_changed.emit('plex.base_url', self.plex_url_input.text()) self.settings_changed.emit('plex.token', self.plex_token_input.text()) self.settings_changed.emit('soulseek.slskd_url', self.slskd_url_input.text()) @@ -1143,6 +1205,43 @@ class SettingsPage(QWidget): } self.start_service_test('spotify', test_config) + def test_tidal_connection(self): + """Test Tidal API connection in background thread""" + test_config = { + 'client_id': self.tidal_client_id_input.text(), + 'client_secret': self.tidal_client_secret_input.text() + } + self.start_service_test('tidal', test_config) + + def authenticate_tidal(self): + """Manually trigger Tidal OAuth authentication""" + try: + from core.tidal_client import TidalClient + + # Make sure we have the current settings + config_manager.set('tidal.client_id', self.tidal_client_id_input.text()) + config_manager.set('tidal.client_secret', self.tidal_client_secret_input.text()) + + # Create client and authenticate + client = TidalClient() + + self.tidal_auth_btn.setText("🔐 Authenticating...") + self.tidal_auth_btn.setEnabled(False) + + if client.authenticate(): + QMessageBox.information(self, "Success", "✓ Tidal authentication successful!\nYou can now use Tidal playlists.") + self.tidal_auth_btn.setText("✅ Authenticated") + else: + QMessageBox.warning(self, "Authentication Failed", "✗ Tidal authentication failed.\nPlease check your credentials and try again.") + self.tidal_auth_btn.setText("🔐 Authenticate") + + self.tidal_auth_btn.setEnabled(True) + + except Exception as e: + self.tidal_auth_btn.setText("🔐 Authenticate") + self.tidal_auth_btn.setEnabled(True) + QMessageBox.critical(self, "Error", f"Failed to authenticate with Tidal:\n{str(e)}") + def test_active_server_connection(self): """Test the currently active (or pending) media server connection""" # Determine which server to test (pending change takes priority) @@ -1970,6 +2069,95 @@ class SettingsPage(QWidget): helper_text.setWordWrap(True) spotify_layout.addWidget(helper_text) + # Tidal settings + tidal_frame = QFrame() + tidal_frame.setStyleSheet(""" + QFrame { + background: #333333; + border: 1px solid #444444; + border-radius: 8px; + padding: 8px; + } + """) + tidal_layout = QVBoxLayout(tidal_frame) + tidal_layout.setSpacing(8) + + tidal_title = QLabel("Tidal") + tidal_title.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + tidal_title.setStyleSheet("color: #ff6600;") + tidal_layout.addWidget(tidal_title) + + # Client ID + tidal_client_id_label = QLabel("Client ID:") + tidal_client_id_label.setStyleSheet(self.get_label_style(11)) + tidal_layout.addWidget(tidal_client_id_label) + + self.tidal_client_id_input = QLineEdit() + self.tidal_client_id_input.setStyleSheet(self.get_input_style()) + self.tidal_client_id_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.form_inputs['tidal.client_id'] = self.tidal_client_id_input + tidal_layout.addWidget(self.tidal_client_id_input) + + # Client Secret + tidal_client_secret_label = QLabel("Client Secret:") + tidal_client_secret_label.setStyleSheet(self.get_label_style(11)) + tidal_layout.addWidget(tidal_client_secret_label) + + self.tidal_client_secret_input = QLineEdit() + self.tidal_client_secret_input.setEchoMode(QLineEdit.EchoMode.Password) + self.tidal_client_secret_input.setStyleSheet(self.get_input_style()) + self.tidal_client_secret_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.form_inputs['tidal.client_secret'] = self.tidal_client_secret_input + tidal_layout.addWidget(self.tidal_client_secret_input) + + # Helper text for Tidal + tidal_helper_text = QLabel("Configure Tidal API credentials for playlist sync functionality") + tidal_helper_text.setStyleSheet("color: #ffffff; font-size: 10px; font-style: italic; background: transparent;") + tidal_helper_text.setWordWrap(True) + tidal_layout.addWidget(tidal_helper_text) + + # OAuth info + oauth_info_label = QLabel("Required Redirect URI:") + oauth_info_label.setStyleSheet("color: #ffffff; font-size: 11px; margin-top: 8px; background: transparent;") + tidal_layout.addWidget(oauth_info_label) + + oauth_url_label = QLabel("http://127.0.0.1:8889/tidal/callback") + oauth_url_label.setStyleSheet(""" + color: #ff6600; + font-size: 11px; + font-family: 'Courier New', monospace; + background: #2a2a2a; + border: 1px solid #444444; + border-radius: 4px; + padding: 6px 8px; + margin-bottom: 8px; + """) + oauth_url_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + tidal_layout.addWidget(oauth_url_label) + + # Authenticate button + self.tidal_auth_btn = QPushButton("🔐 Authenticate") + self.tidal_auth_btn.setFixedHeight(30) + self.tidal_auth_btn.setStyleSheet(""" + QPushButton { + background: #ff6600; + border: none; + border-radius: 15px; + color: #ffffff; + font-size: 11px; + font-weight: bold; + margin-top: 8px; + } + QPushButton:hover { + background: #ff7700; + } + QPushButton:pressed { + background: #e55500; + } + """) + self.tidal_auth_btn.clicked.connect(self.authenticate_tidal) + tidal_layout.addWidget(self.tidal_auth_btn) + # Server Selection Toggle Buttons server_selection_container = QWidget() server_selection_container.setStyleSheet("background: transparent;") @@ -2188,6 +2376,7 @@ class SettingsPage(QWidget): soulseek_layout.addWidget(self.api_key_input) api_layout.addWidget(spotify_frame) + api_layout.addWidget(tidal_frame) api_layout.addWidget(server_selection_container) api_layout.addWidget(self.plex_container) api_layout.addWidget(self.jellyfin_container) @@ -2203,6 +2392,12 @@ class SettingsPage(QWidget): self.test_buttons['spotify'].clicked.connect(self.test_spotify_connection) self.test_buttons['spotify'].setStyleSheet(self.get_test_button_style()) + self.test_buttons['tidal'] = QPushButton("Test Tidal") + self.test_buttons['tidal'].setFixedHeight(30) + self.test_buttons['tidal'].setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.test_buttons['tidal'].clicked.connect(self.test_tidal_connection) + self.test_buttons['tidal'].setStyleSheet(self.get_test_button_style()) + self.test_buttons['server'] = QPushButton("Test Server") self.test_buttons['server'].setFixedHeight(30) self.test_buttons['server'].setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) @@ -2216,6 +2411,7 @@ class SettingsPage(QWidget): self.test_buttons['soulseek'].setStyleSheet(self.get_test_button_style()) test_layout.addWidget(self.test_buttons['spotify']) + test_layout.addWidget(self.test_buttons['tidal']) test_layout.addWidget(self.test_buttons['server']) test_layout.addWidget(self.test_buttons['soulseek']) diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 9fa69f16..8e3ce647 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -13,6 +13,7 @@ from typing import List, Optional from core.soulseek_client import TrackResult import re import asyncio +import time from core.matching_engine import MusicMatchingEngine from core.wishlist_service import get_wishlist_service from ui.components.toast_manager import ToastType @@ -21,6 +22,7 @@ from core.plex_scan_manager import PlexScanManager from utils.logging_config import get_logger import yt_dlp from core.spotify_client import Track, Playlist +from core.tidal_client import TidalClient logger = get_logger("sync") @@ -876,6 +878,43 @@ class PlaylistLoaderThread(QThread): except Exception as e: self.loading_failed.emit(str(e)) +class TidalPlaylistLoaderThread(QThread): + playlist_loaded = pyqtSignal(object) # Single playlist + loading_finished = pyqtSignal(int) # Total count + loading_failed = pyqtSignal(str) # Error message + progress_updated = pyqtSignal(str) # Progress text + + def __init__(self, tidal_client): + super().__init__() + self.tidal_client = tidal_client + + def run(self): + try: + self.progress_updated.emit("Connecting to Tidal...") + if not self.tidal_client: + self.loading_failed.emit("Tidal client not available") + return + + # Try to ensure authentication (will trigger OAuth if needed) + if not self.tidal_client.is_authenticated(): + self.progress_updated.emit("Authenticating with Tidal...") + if not self.tidal_client._ensure_valid_token(): + self.loading_failed.emit("Tidal authentication failed. Please check your settings and complete OAuth flow.") + return + + self.progress_updated.emit("Fetching playlists...") + playlists = self.tidal_client.get_user_playlists_metadata_only() + + for i, playlist in enumerate(playlists): + self.progress_updated.emit(f"Loading playlist {i+1}/{len(playlists)}: {playlist.name}") + self.playlist_loaded.emit(playlist) + self.msleep(20) # Reduced delay for faster but visible progressive loading + + self.loading_finished.emit(len(playlists)) + + except Exception as e: + self.loading_failed.emit(str(e)) + class TrackLoadingWorkerSignals(QObject): """Signals for async track loading worker""" tracks_loaded = pyqtSignal(str, list) # playlist_id, tracks @@ -1681,6 +1720,18 @@ class PlaylistDetailsModal(QDialog): if self.parent_page and self.parent_page.start_playlist_sync(self.playlist): self.is_syncing = True + # Update Tidal card state to syncing (matches YouTube workflow) + if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): + print(f"🎵 Updating Tidal card state to syncing for playlist_id: {self.playlist_id}") + if hasattr(self.parent_page, 'update_tidal_card_phase'): + self.parent_page.update_tidal_card_phase(self.playlist_id, 'syncing') + + # Update YouTube card state to syncing (existing logic) + if hasattr(self, 'youtube_url'): + print(f"🎬 Updating YouTube card state to syncing for URL: {self.youtube_url}") + if hasattr(self.parent_page, 'update_youtube_card_phase'): + self.parent_page.update_youtube_card_phase(self.youtube_url, 'syncing') + # Update modal UI state self.set_sync_button_state(True) @@ -2380,6 +2431,277 @@ class PlaylistItem(QFrame): self.download_modal.activateWindow() self.download_modal.raise_() +class TidalPlaylistCard(QFrame): + """Tidal playlist card with persistent state tracking across all phases (matches YouTube workflow)""" + card_clicked = pyqtSignal(str, str) # Signal: (playlist_id, phase) + + def __init__(self, playlist_id: str, playlist_name: str = "Loading...", track_count: int = 0, parent=None): + super().__init__(parent) + self.playlist_id = playlist_id + self.playlist_name = playlist_name + self.track_count = track_count + self.phase = "discovering" # discovering, discovery_complete, syncing, sync_complete, downloading, download_complete + self.progress_data = {'total': 0, 'matched': 0, 'failed': 0} + + # Modal references + self.discovery_modal = None + self.download_modal = None + + # State data + self.playlist_data = None + self.discovered_tracks = [] + + self.setup_ui() + self.update_display() + + def setup_ui(self): + self.setFixedHeight(80) + self.setStyleSheet(""" + TidalPlaylistCard { + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + TidalPlaylistCard:hover { + background: #333333; + border: 1px solid #ff6600; + } + """) + + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + + layout = QHBoxLayout(self) + layout.setContentsMargins(20, 15, 20, 15) + layout.setSpacing(15) + + # Tidal icon indicator + tidal_icon = QLabel("🎵") + tidal_icon.setFixedSize(24, 24) + tidal_icon.setStyleSheet(""" + QLabel { + color: #ff6600; + font-size: 16px; + font-weight: bold; + background: transparent; + text-align: center; + border-radius: 12px; + border: 1px solid #ff6600; + } + """) + tidal_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Content layout + content_layout = QVBoxLayout() + content_layout.setSpacing(4) + + # Playlist name + self.name_label = EllipsisLabel(self.playlist_name) + self.name_label.setStyleSheet(""" + QLabel { + color: #ffffff; + font-size: 14px; + font-weight: bold; + background: transparent; + } + """) + + # Info row (track count + phase) + info_layout = QHBoxLayout() + info_layout.setSpacing(8) + info_layout.setContentsMargins(0, 0, 0, 0) + + # Track count + self.track_label = QLabel(f"{self.track_count} tracks") + self.track_label.setStyleSheet(""" + QLabel { + color: #b3b3b3; + font-size: 11px; + background: transparent; + } + """) + + # Phase indicator + self.phase_label = QLabel(self.get_phase_text()) + self.phase_label.setStyleSheet(""" + QLabel { + color: #ff6600; + font-size: 11px; + background: transparent; + font-weight: bold; + } + """) + + info_layout.addWidget(self.track_label) + info_layout.addWidget(self.phase_label) + info_layout.addStretch() + + content_layout.addWidget(self.name_label) + content_layout.addLayout(info_layout) + + # Progress widget (hidden by default, shown during syncing/downloading) + self.progress_widget = self.create_progress_display() + self.progress_widget.hide() + + # Action button + self.action_btn = QPushButton("Discover Matches") + self.action_btn.setFixedSize(120, 30) + self.action_btn.setStyleSheet(""" + QPushButton { + background: #ff6600; + border: none; + border-radius: 15px; + color: #ffffff; + font-size: 10px; + font-weight: bold; + } + QPushButton:hover { + background: #ff7700; + } + QPushButton:pressed { + background: #e55500; + } + """) + self.action_btn.clicked.connect(self.on_action_clicked) + + layout.addWidget(tidal_icon) + layout.addLayout(content_layout) + layout.addWidget(self.progress_widget) + layout.addStretch() + layout.addWidget(self.action_btn) + + def create_progress_display(self): + """Create sync status display widget like YouTubePlaylistCard""" + sync_status = QFrame() + sync_status.setFixedHeight(30) + sync_status.setStyleSheet(""" + QFrame { + background: rgba(0, 0, 0, 0.3); + border-radius: 15px; + border: 1px solid rgba(255, 255, 255, 0.1); + } + """) + + layout = QHBoxLayout(sync_status) + layout.setContentsMargins(12, 6, 12, 6) + layout.setSpacing(8) + + # Create labels for progress display + self.total_tracks_label = QLabel(f"♪ {self.progress_data['total']}") + self.total_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) + self.total_tracks_label.setStyleSheet("color: #b3b3b3; background: transparent;") + + self.matched_tracks_label = QLabel(f"✓ {self.progress_data['matched']}") + self.matched_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) + self.matched_tracks_label.setStyleSheet("color: #1db954; background: transparent;") + + self.failed_tracks_label = QLabel(f"✗ {self.progress_data['failed']}") + self.failed_tracks_label.setFont(QFont("SF Pro Text", 9, QFont.Weight.Medium)) + self.failed_tracks_label.setStyleSheet("color: #e22134; background: transparent;") + + layout.addWidget(self.total_tracks_label) + layout.addWidget(self.matched_tracks_label) + layout.addWidget(self.failed_tracks_label) + layout.addStretch() + + return sync_status + + def get_phase_text(self): + """Get display text for current phase""" + phase_texts = { + "discovering": "Ready to discover", + "discovery_complete": "Discovery complete", + "syncing": "Finding matches...", + "sync_complete": "Sync complete", + "downloading": "Downloading...", + "download_complete": "Download complete" + } + return phase_texts.get(self.phase, self.phase) + + def get_action_text(self): + """Get text for action button based on current phase""" + action_texts = { + "discovering": "Discover Matches", + "discovery_complete": "View Results", + "syncing": "View Progress", + "sync_complete": "Download Missing", + "downloading": "View Downloads", + "download_complete": "View Downloads" + } + return action_texts.get(self.phase, "Discover Matches") + + def update_phase_style(self): + """Update styling based on current phase""" + if self.phase in ['discovering']: + self.phase_label.setStyleSheet("color: #ff6600; font-size: 11px; background: transparent; font-weight: bold;") + elif self.phase in ['discovery_complete', 'sync_complete']: + self.phase_label.setStyleSheet("color: #1db954; font-size: 11px; background: transparent; font-weight: bold;") + elif self.phase in ['syncing', 'downloading']: + self.phase_label.setStyleSheet("color: #1db954; font-size: 11px; background: transparent; font-weight: bold;") + elif self.phase in ['download_complete']: + self.phase_label.setStyleSheet("color: #1db954; font-size: 11px; background: transparent; font-weight: bold;") + + def update_display(self): + """Update all display elements based on current state""" + self.name_label.setText(self.playlist_name) + self.track_label.setText(f"{self.track_count} tracks") + self.phase_label.setText(self.get_phase_text()) + self.action_btn.setText(self.get_action_text()) + self.update_phase_style() + + def set_phase(self, phase: str): + """Update the current phase and refresh display""" + self.phase = phase + self.update_display() + + # Show/hide progress widget based on phase + if phase in ['syncing', 'downloading', 'sync_complete']: + print(f"🎵 Tidal card phase set to {phase} - showing progress widget") + self.progress_widget.show() + self.action_btn.hide() + # For syncing phase, initialize with current progress data + if phase == 'syncing': + # Ensure we show some initial progress data + if self.progress_data['total'] == 0: + # Initialize with track count if available + self.progress_data['total'] = self.track_count + self.total_tracks_label.setText(f"♪ {self.progress_data['total']}") + self.matched_tracks_label.setText(f"✓ {self.progress_data['matched']}") + self.failed_tracks_label.setText(f"✗ {self.progress_data['failed']}") + # For sync_complete, hide progress after a delay to show final results + elif phase == 'sync_complete': + from PyQt6.QtCore import QTimer + QTimer.singleShot(5000, lambda: self.progress_widget.hide() if self.phase == 'sync_complete' else None) + QTimer.singleShot(5000, lambda: self.action_btn.show() if self.phase == 'sync_complete' else None) + else: + self.progress_widget.hide() + self.action_btn.show() + + def update_playlist_info(self, name: str, track_count: int): + """Update playlist information and refresh display""" + self.playlist_name = name + self.track_count = track_count + self.update_display() + + def update_progress(self, total: int, matched: int, failed: int): + """Update progress data and refresh progress display""" + self.progress_data = {'total': total, 'matched': matched, 'failed': failed} + if self.progress_widget.isVisible(): + self.total_tracks_label.setText(f"♪ {total}") + self.matched_tracks_label.setText(f"✓ {matched}") + self.failed_tracks_label.setText(f"✗ {failed}") + + def on_action_clicked(self): + """Handle action button click - emit signal with current phase""" + self.card_clicked.emit(self.playlist_id, self.phase) + + def mousePressEvent(self, event): + """Handle card clicks""" + if event.button() == Qt.MouseButton.LeftButton: + self.card_clicked.emit(self.playlist_id, self.phase) + super().mousePressEvent(event) + class YouTubePlaylistCard(QFrame): """YouTube playlist card with persistent state tracking across all phases""" card_clicked = pyqtSignal(str, str) # Signal: (url, phase) @@ -2764,16 +3086,19 @@ class SyncPage(QWidget): sync_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time database_updated_externally = pyqtSignal() - def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, jellyfin_client=None, parent=None): + def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, jellyfin_client=None, tidal_client=None, parent=None): super().__init__(parent) self.spotify_client = spotify_client self.plex_client = plex_client self.jellyfin_client = jellyfin_client self.soulseek_client = soulseek_client + self.tidal_client = tidal_client or TidalClient() self.downloads_page = downloads_page self.sync_statuses = load_sync_status() self.current_playlists = [] self.playlist_loader = None + self.current_tidal_playlists = [] + self.tidal_playlist_loader = None self.active_download_processes = {} # Track cache for performance self.track_cache = {} # playlist_id -> tracks @@ -2804,6 +3129,11 @@ class SyncPage(QWidget): self.youtube_cards = {} # url -> YouTubePlaylistCard instance self.youtube_cards_container = None # Container for all YouTube cards + # Tidal playlist card hub system (identical to YouTube) + self.tidal_playlist_states = {} # playlist_id -> {phase, data, card, modals} + self.tidal_cards = {} # playlist_id -> TidalPlaylistCard instance + self.tidal_cards_container = None # Container for all Tidal cards + # Initialize unified media scan manager self.scan_manager = None try: @@ -3322,8 +3652,36 @@ class SyncPage(QWidget): if not youtube_card_updated: print(f"🎬 ❌ No matching YouTube card found for playlist_id: {playlist_id}") - if not playlist_item and not youtube_card_updated: - print(f"🚀 No playlist widget OR YouTube card found for playlist_id: {playlist_id}") + # Update Tidal card progress (for Tidal playlists) + # Find the Tidal card by matching playlist IDs + tidal_card_updated = False + print(f"🎵 Searching for Tidal card with playlist_id: {playlist_id}") + for tidal_playlist_id, state in self.tidal_playlist_states.items(): + playlist_data = state.get('playlist_data') + if playlist_data and hasattr(playlist_data, 'id'): + print(f"🎵 Checking Tidal card: tidal_playlist_id={tidal_playlist_id}, stored playlist_id={playlist_data.id}") + if playlist_data.id == playlist_id: + print(f"🎵 ✅ Found matching Tidal card for playlist_id: {playlist_id}, updating progress") + # Update card progress display + if tidal_playlist_id in self.tidal_cards: + card = self.tidal_cards[tidal_playlist_id] + card.update_progress( + total=progress.total_tracks, + matched=progress.matched_tracks, + failed=progress.failed_tracks + ) + tidal_card_updated = True + break + else: + print(f"🎵 ❌ Playlist ID mismatch: {playlist_data.id} != {playlist_id}") + else: + print(f"🎵 Tidal card state missing playlist_data or id: tidal_playlist_id={tidal_playlist_id}") + + if not tidal_card_updated: + print(f"🎵 ❌ No matching Tidal card found for playlist_id: {playlist_id}") + + if not playlist_item and not youtube_card_updated and not tidal_card_updated: + print(f"🚀 No playlist widget, YouTube card, OR Tidal card found for playlist_id: {playlist_id}") # Update any open modal for this playlist print(f"🚀 About to call update_open_modals_progress") @@ -3366,6 +3724,25 @@ class SyncPage(QWidget): playlist_name = playlist_data.name youtube_card_updated = True break + + # Update Tidal card status (for Tidal playlists) + tidal_card_updated = False + for tidal_playlist_id, state in self.tidal_playlist_states.items(): + playlist_data = state.get('playlist_data') + if playlist_data and hasattr(playlist_data, 'id') and playlist_data.id == playlist_id: + print(f"🎵 Tidal sync finished for playlist_id: {playlist_id}, updating card to sync_complete") + self.update_tidal_card_phase(tidal_playlist_id, 'sync_complete') + # Also update card progress display + if tidal_playlist_id in self.tidal_cards: + card = self.tidal_cards[tidal_playlist_id] + card.update_progress( + total=result.total_tracks, + matched=result.matched_tracks, + failed=result.failed_tracks + ) + playlist_name = playlist_data.name + tidal_card_updated = True + break # Update any open modals self.update_open_modals_completion(playlist_id, result) @@ -3806,6 +4183,10 @@ class SyncPage(QWidget): spotify_tab = self.create_spotify_playlist_tab() self.playlist_tabs.addTab(spotify_tab, "Spotify Playlists") + # Create Tidal tab + tidal_tab = self.create_tidal_playlist_tab() + self.playlist_tabs.addTab(tidal_tab, "Tidal Playlists") + # Create YouTube tab (placeholder for now) youtube_tab = self.create_youtube_playlist_tab() self.playlist_tabs.addTab(youtube_tab, "YouTube Playlists") @@ -3888,6 +4269,87 @@ class SyncPage(QWidget): return tab + def create_tidal_playlist_tab(self): + """Create the Tidal playlist tab (similar to Spotify but opens discovery modal)""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(15) + layout.setContentsMargins(15, 15, 15, 15) + + # Section header + header_layout = QHBoxLayout() + + section_title = QLabel("Your Tidal Playlists") + section_title.setFont(QFont("Arial", 16, QFont.Weight.Bold)) + section_title.setStyleSheet("color: #ffffff;") + + self.tidal_refresh_btn = QPushButton("🔄 Refresh") + self.tidal_refresh_btn.setFixedSize(100, 35) + self.tidal_refresh_btn.clicked.connect(self.load_tidal_playlists_async) + self.tidal_refresh_btn.setStyleSheet(""" + QPushButton { + background: #ff6600; + border: none; + border-radius: 17px; + color: #ffffff; + font-size: 11px; + font-weight: bold; + } + QPushButton:hover { + background: #ff7700; + } + QPushButton:pressed { + background: #e55500; + } + QPushButton:disabled { + background: #666666; + color: #999999; + } + """) + + header_layout.addWidget(section_title) + header_layout.addStretch() + header_layout.addWidget(self.tidal_refresh_btn) + + # Playlist area (scrollable) + playlist_container = QScrollArea() + playlist_container.setWidgetResizable(True) + playlist_container.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + playlist_container.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + playlist_container.setStyleSheet(""" + QScrollArea { + border: none; + background: transparent; + } + QScrollBar:vertical { + background: #2a2a2a; + width: 12px; + border-radius: 6px; + } + QScrollBar::handle:vertical { + background: #555555; + border-radius: 6px; + min-height: 20px; + } + QScrollBar::handle:vertical:hover { + background: #666666; + } + """) + + # This will hold all playlist items + self.tidal_playlist_widget = QWidget() + self.tidal_playlist_layout = QVBoxLayout(self.tidal_playlist_widget) + self.tidal_playlist_layout.setSpacing(8) + self.tidal_playlist_layout.setContentsMargins(0, 0, 0, 0) + self.tidal_playlist_layout.addStretch() # Push items to top + + playlist_container.setWidget(self.tidal_playlist_widget) + + layout.addLayout(header_layout) + layout.addWidget(playlist_container) + + return tab + def create_youtube_playlist_tab(self): """Create the YouTube playlist tab (placeholder for future implementation)""" tab = QWidget() @@ -4446,6 +4908,446 @@ class SyncPage(QWidget): """Update progress text""" self.log_area.append(message) + def load_tidal_playlists_async(self): + """Start asynchronous Tidal playlist loading""" + if self.tidal_playlist_loader and self.tidal_playlist_loader.isRunning(): + return + + # Complete cleanup of all Tidal operations before refresh + self.cleanup_all_tidal_operations() + + # Clear existing Tidal playlists + self.clear_tidal_playlists() + + # Add loading placeholder + loading_label = QLabel("🔄 Loading Tidal playlists...") + loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + loading_label.setStyleSheet(""" + QLabel { + color: #b3b3b3; + font-size: 14px; + padding: 40px; + background: #282828; + border-radius: 8px; + border: 1px solid #404040; + } + """) + self.tidal_playlist_layout.insertWidget(0, loading_label) + + # Show loading state + self.tidal_refresh_btn.setText("🔄 Loading...") + self.tidal_refresh_btn.setEnabled(False) + self.log_area.append("Starting Tidal playlist loading...") + + # Create and start loader thread + self.tidal_playlist_loader = TidalPlaylistLoaderThread(self.tidal_client) + self.tidal_playlist_loader.playlist_loaded.connect(self.add_tidal_playlist_to_ui) + self.tidal_playlist_loader.loading_finished.connect(self.on_tidal_loading_finished) + self.tidal_playlist_loader.loading_failed.connect(self.on_tidal_loading_failed) + self.tidal_playlist_loader.progress_updated.connect(self.update_progress) + self.tidal_playlist_loader.start() + + def add_tidal_playlist_to_ui(self, playlist): + """Add a single Tidal playlist to the UI as it's loaded""" + # Create a TidalPlaylistCard that matches YouTube card workflow + card = TidalPlaylistCard(playlist.id, playlist.name, len(playlist.tracks) if hasattr(playlist, 'tracks') else 0, self) + + # Store card reference + self.tidal_cards[playlist.id] = card + + # Initialize state tracking + self.tidal_playlist_states[playlist.id] = { + 'phase': 'discovering', + 'playlist_data': None, + 'discovered_tracks': [], + 'card': card, + 'discovery_modal': None, + 'download_modal': None, + 'original_name': playlist.name, # Store original name for resets + 'original_track_count': len(playlist.tracks) if hasattr(playlist, 'tracks') else 0 + } + + # Add to layout and store reference + self.tidal_playlist_layout.insertWidget(self.tidal_playlist_layout.count() - 1, card) + self.current_tidal_playlists.append(playlist) + + # Connect to click handler (new card-based system) + card.card_clicked.connect(self.on_tidal_card_clicked) + + def on_tidal_loading_finished(self, count): + """Handle completion of Tidal playlist loading""" + # Remove loading placeholder if it exists + for i in range(self.tidal_playlist_layout.count()): + item = self.tidal_playlist_layout.itemAt(i) + if item and item.widget() and isinstance(item.widget(), QLabel): + if "Loading Tidal playlists" in item.widget().text(): + item.widget().deleteLater() + break + + self.tidal_refresh_btn.setText("🔄 Refresh") + self.tidal_refresh_btn.setEnabled(True) + self.log_area.append(f"✓ Loaded {count} Tidal playlists successfully") + + def on_tidal_loading_failed(self, error_msg): + """Handle Tidal playlist loading failure""" + # Remove loading placeholder if it exists + for i in range(self.tidal_playlist_layout.count()): + item = self.tidal_playlist_layout.itemAt(i) + if item and item.widget() and isinstance(item.widget(), QLabel): + if "Loading Tidal playlists" in item.widget().text(): + item.widget().deleteLater() + break + + self.tidal_refresh_btn.setText("🔄 Refresh") + self.tidal_refresh_btn.setEnabled(True) + self.log_area.append(f"✗ Failed to load Tidal playlists: {error_msg}") + QMessageBox.critical(self, "Error", f"Failed to load Tidal playlists: {error_msg}") + + def cleanup_all_tidal_operations(self): + """Complete cleanup of all Tidal operations - stop workers, close modals, cancel syncs""" + print("🧹 Starting complete Tidal cleanup for refresh...") + + # Close and cleanup all active Tidal modals + for playlist_id, state in list(self.tidal_playlist_states.items()): + # Close discovery modals + discovery_modal = state.get('discovery_modal') + if discovery_modal: + print(f"🔍 Closing Tidal discovery modal for playlist_id: {playlist_id}") + try: + # Cancel any active workers in the discovery modal + if hasattr(discovery_modal, 'spotify_worker') and discovery_modal.spotify_worker: + discovery_modal.spotify_worker.cancel() + discovery_modal.spotify_worker = None + + # Cancel any active sync operations + if hasattr(discovery_modal, 'sync_in_progress') and discovery_modal.sync_in_progress: + if hasattr(self, 'cancel_playlist_sync') and hasattr(discovery_modal, 'playlist'): + self.cancel_playlist_sync(discovery_modal.playlist.id) + + # Force close the modal + discovery_modal.close() + except Exception as e: + print(f"⚠️ Error closing discovery modal: {e}") + + # Close download modals + download_modal = state.get('download_modal') + if download_modal: + print(f"📥 Closing Tidal download modal for playlist_id: {playlist_id}") + try: + # Cancel all operations (downloads, searches, etc.) + download_modal.cancel_operations() + + # Cancel any additional search workers that might be running + if hasattr(download_modal, 'parallel_search_tracking'): + download_modal.parallel_search_tracking.clear() + + # Stop any active timers + if hasattr(download_modal, 'download_status_timer'): + download_modal.download_status_timer.stop() + + # Clear any queued operations + if hasattr(download_modal, 'active_downloads'): + download_modal.active_downloads.clear() + + # Force close the modal + download_modal.close() + except Exception as e: + print(f"⚠️ Error closing download modal: {e}") + + # Cancel any active sync workers for Tidal playlists + tidal_playlist_ids = set() + for playlist_id, state in self.tidal_playlist_states.items(): + playlist_data = state.get('playlist_data') + if playlist_data and hasattr(playlist_data, 'id'): + tidal_playlist_ids.add(playlist_data.id) + + # Cancel sync workers + for playlist_id in tidal_playlist_ids: + if playlist_id in self.active_sync_workers: + print(f"🔄 Cancelling sync worker for Tidal playlist_id: {playlist_id}") + try: + worker = self.active_sync_workers[playlist_id] + if hasattr(worker, 'cancel'): + worker.cancel() + del self.active_sync_workers[playlist_id] + except Exception as e: + print(f"⚠️ Error cancelling sync worker: {e}") + + # Remove from active download modals (shared with YouTube) + for playlist_id in list(self.active_youtube_download_modals.keys()): + modal = self.active_youtube_download_modals[playlist_id] + if hasattr(modal, 'is_tidal_playlist') and modal.is_tidal_playlist: + print(f"📥 Removing Tidal download modal from active list: {playlist_id}") + try: + del self.active_youtube_download_modals[playlist_id] + except Exception as e: + print(f"⚠️ Error removing download modal: {e}") + + # Force cleanup of any remaining thread pool operations + # This ensures any lingering search workers are properly terminated + try: + thread_pool = QThreadPool.globalInstance() + # Note: QThreadPool doesn't have a direct "cancel all" method, + # but setting cancel_requested=True in the modals should make workers exit gracefully + print(f"🔧 Thread pool active count: {thread_pool.activeThreadCount()}") + if thread_pool.activeThreadCount() > 0: + print("⏳ Waiting briefly for thread pool workers to finish gracefully...") + # Give workers a moment to see the cancel_requested flag and exit + from PyQt6.QtCore import QTimer, QEventLoop + loop = QEventLoop() + QTimer.singleShot(500, loop.quit) # 500ms timeout + loop.exec() + except Exception as e: + print(f"⚠️ Error during thread pool cleanup: {e}") + + print("✅ Tidal cleanup complete") + + def clear_tidal_playlists(self): + """Clear all Tidal playlist items from UI""" + for i in reversed(range(self.tidal_playlist_layout.count())): + layout_item = self.tidal_playlist_layout.itemAt(i) + if layout_item: + widget = layout_item.widget() + if widget: + # Remove TidalPlaylistCard widgets and skip static UI elements + # (like refresh buttons, labels, etc.) + if hasattr(widget, 'playlist_id') or isinstance(widget, TidalPlaylistCard): + widget.setParent(None) + self.current_tidal_playlists.clear() + + # Clear the state tracking as well + self.tidal_cards.clear() + self.tidal_playlist_states.clear() + + def on_tidal_card_clicked(self, playlist_id: str, phase: str): + """Handle Tidal playlist card clicks - route to appropriate modal (matches YouTube workflow)""" + print(f"🎵 Tidal card clicked: playlist_id={playlist_id}, Phase={phase}") + + state = self.get_tidal_playlist_state(playlist_id) + if not state: + print(f"⚠️ No state found for playlist_id: {playlist_id}") + return + + # Route to appropriate modal based on current phase + if phase in ['discovering', 'discovery_complete']: + self.open_or_create_tidal_discovery_modal(playlist_id, state) + elif phase in ['sync_complete', 'downloading', 'download_complete']: + # For sync_complete phase, open discovery modal with "Download Missing" button + if phase == 'sync_complete': + self.open_or_create_tidal_discovery_modal(playlist_id, state) + else: + # For downloading/download_complete phases, check if download modal actually exists + # If not, route back to discovery modal (handles case where download modal was closed) + playlist_data = state.get('playlist_data') + download_modal = state.get('download_modal') + if download_modal and not download_modal.isVisible(): + # Modal exists but is hidden - show it + print(f"📍 Reopening hidden Tidal download modal for playlist_id: {playlist_id}") + download_modal.show() + download_modal.activateWindow() + download_modal.raise_() + elif download_modal and download_modal.isVisible(): + # Modal is already visible - bring to front + print(f"📍 Bringing visible Tidal download modal to front for playlist_id: {playlist_id}") + download_modal.activateWindow() + download_modal.raise_() + else: + print(f"📍 No download modal found, routing to discovery modal instead") + self.open_or_create_tidal_discovery_modal(playlist_id, state) + elif phase == 'syncing': + # Show sync progress - route to discovery modal + self.open_or_create_tidal_discovery_modal(playlist_id, state) + + def open_or_create_tidal_discovery_modal(self, playlist_id: str, state: dict): + """Open or create the discovery modal for a Tidal playlist""" + # Check if modal already exists and is visible + if state.get('discovery_modal') and state['discovery_modal'].isVisible(): + state['discovery_modal'].activateWindow() + state['discovery_modal'].raise_() + return + + # Check if modal exists but is hidden - reopen it + if state.get('discovery_modal') and not state['discovery_modal'].isVisible(): + print(f"🔍 Reopening existing hidden discovery modal for playlist_id: {playlist_id}") + state['discovery_modal'].show() + state['discovery_modal'].activateWindow() + state['discovery_modal'].raise_() + return + + # Check if we have playlist data already (discovery_complete state) + if state.get('playlist_data') and state['phase'] == 'discovery_complete': + print(f"🔍 Opening existing discovery modal with data for playlist_id: {playlist_id}") + + # Create a new modal with the existing data + dummy_playlist_item = type('DummyPlaylistItem', (), { + 'playlist_name': state['playlist_data'].name, + 'track_count': len(state['playlist_data'].tracks), + 'download_modal': None, + 'show_operation_status': lambda self, status_text="View Progress": None, + 'hide_operation_status': lambda self: None + })() + + # Create the discovery modal using the existing data + modal = YouTubeDownloadMissingTracksModal( + state['playlist_data'], + dummy_playlist_item, + self, + self.downloads_page + ) + + # Mark this as a Tidal workflow + modal.is_tidal_playlist = True + modal.tidal_playlist = state['playlist_data'] + modal.playlist_id = playlist_id # For state tracking + + # Store modal reference in state + state['discovery_modal'] = modal + + # Show the modal + modal.show() + modal.activateWindow() + modal.raise_() + return + + # Need to discover playlist data first + print(f"🔍 Need to discover playlist data for playlist_id: {playlist_id}") + + # Get playlist data if not cached + playlist_data = state.get('playlist_data') + if not playlist_data: + # Try to get playlist from current loaded playlists + playlist_data = None + for playlist in self.current_tidal_playlists: + if hasattr(playlist, 'id') and playlist.id == playlist_id: + playlist_data = playlist + break + + if not playlist_data: + print(f"❌ Could not find playlist data for playlist_id: {playlist_id}") + return + + # Get full playlist data with tracks if not already loaded + if not hasattr(playlist_data, 'tracks') or not playlist_data.tracks: + try: + full_playlist = self.tidal_client.get_playlist(playlist_id) + if full_playlist and full_playlist.tracks: + playlist_data = full_playlist + else: + print(f"❌ Failed to load tracks for Tidal playlist {playlist_id}") + QMessageBox.warning(self, "Error", f"Failed to load tracks for playlist") + return + except Exception as e: + print(f"❌ Error loading Tidal playlist tracks: {e}") + QMessageBox.warning(self, "Error", f"Error loading playlist tracks: {str(e)}") + return + + # Create a dummy playlist item for the modal + dummy_playlist_item = type('DummyPlaylistItem', (), { + 'playlist_name': playlist_data.name, + 'track_count': len(playlist_data.tracks), + 'download_modal': None, + 'show_operation_status': lambda self, status_text="View Progress": None, + 'hide_operation_status': lambda self: None + })() + + # Create the discovery modal + modal = YouTubeDownloadMissingTracksModal( + playlist_data, + dummy_playlist_item, + self, + self.downloads_page + ) + + # Mark this as a Tidal workflow + modal.is_tidal_playlist = True + modal.tidal_playlist = playlist_data + modal.playlist_id = playlist_id # For state tracking + + # Store playlist data and modal reference in state + state['playlist_data'] = playlist_data + state['discovery_modal'] = modal + + # Show the modal + modal.show() + modal.activateWindow() + modal.raise_() + + print(f"✅ Opened discovery modal for Tidal playlist '{playlist_data.name}' with {len(playlist_data.tracks)} tracks") + + def open_or_create_tidal_download_modal(self, playlist_id: str, state: dict): + """Open or create the download modal for a Tidal playlist""" + playlist_data = state.get('playlist_data') + if not playlist_data: + print(f"⚠️ No playlist data found for download modal") + return + + # Check if download modal already exists + if hasattr(playlist_data, 'id') and playlist_data.id in self.active_youtube_download_modals: + modal = self.active_youtube_download_modals[playlist_data.id] + if modal.isVisible(): + modal.activateWindow() + modal.raise_() + return + else: + # Modal exists but is hidden - show it + modal.show() + modal.activateWindow() + modal.raise_() + return + + # Need to create new download modal - route back to discovery modal for now + print(f"📍 No download modal found, routing to discovery modal") + self.open_or_create_tidal_discovery_modal(playlist_id, state) + + def on_tidal_playlist_clicked(self, playlist): + """Legacy method for old TidalPlaylistItem - route to card system""" + print(f"🎵 Legacy Tidal playlist clicked: {playlist.name} - routing to card system") + + # For now, create a temporary discovery modal (this should be replaced when cards are fully integrated) + # Get full playlist data with tracks if not already loaded + if not hasattr(playlist, 'tracks') or not playlist.tracks: + try: + full_playlist = self.tidal_client.get_playlist(playlist.id) + if full_playlist and full_playlist.tracks: + playlist = full_playlist + else: + print(f"❌ Failed to load tracks for Tidal playlist {playlist.name}") + QMessageBox.warning(self, "Error", f"Failed to load tracks for playlist '{playlist.name}'") + return + except Exception as e: + print(f"❌ Error loading Tidal playlist tracks: {e}") + QMessageBox.warning(self, "Error", f"Error loading playlist tracks: {str(e)}") + return + + # Create a dummy playlist item for the modal (similar to YouTube workflow) + dummy_playlist_item = type('DummyPlaylistItem', (), { + 'playlist_name': playlist.name, + 'track_count': len(playlist.tracks), + 'download_modal': None, + 'show_operation_status': lambda self, status_text="View Progress": None, + 'hide_operation_status': lambda self: None + })() + + # Create the discovery modal using the YouTube modal class + # (it works for any track discovery workflow) + modal = YouTubeDownloadMissingTracksModal( + playlist, + dummy_playlist_item, + self, + self.downloads_page + ) + + # Mark this as a Tidal workflow so it uses the Tidal discovery worker + modal.is_tidal_playlist = True + modal.tidal_playlist = playlist + + # Show the modal + modal.show() + modal.activateWindow() + modal.raise_() + + print(f"✅ Opened discovery modal for Tidal playlist '{playlist.name}' with {len(playlist.tracks)} tracks") + def disable_refresh_button(self, operation_name="Operation"): """Disable refresh button during sync/download operations""" self.refresh_btn.setEnabled(False) @@ -4837,6 +5739,12 @@ class SyncPage(QWidget): if url in self.youtube_cards: card = self.youtube_cards[url] card.update_playlist_info(name, track_count) + + # Store original name and count for resets + if url in self.youtube_playlist_states: + state = self.youtube_playlist_states[url] + state['original_name'] = name + state['original_track_count'] = track_count def set_youtube_card_playlist_data(self, url: str, playlist_data): """Store playlist data for a YouTube card""" @@ -4868,7 +5776,10 @@ class SyncPage(QWidget): if url in self.youtube_cards: card = self.youtube_cards[url] card.set_phase('discovering') - card.update_playlist_info("Loading...", 0) + # Use original name instead of "Loading..." to keep playlist title visible + original_name = state.get('original_name', 'Loading...') + original_count = state.get('original_track_count', 0) + card.update_playlist_info(original_name, original_count) card.update_progress(0, 0, 0) def remove_youtube_playlist_card(self, url: str): @@ -4881,6 +5792,82 @@ class SyncPage(QWidget): if url in self.youtube_playlist_states: del self.youtube_playlist_states[url] + # Tidal state management methods (identical structure to YouTube) + def update_tidal_card_phase(self, playlist_id: str, phase: str): + """Update the Tidal card's phase - cards are the single source of truth for state""" + if playlist_id not in self.tidal_cards or playlist_id not in self.tidal_playlist_states: + return + + card = self.tidal_cards[playlist_id] + state = self.tidal_playlist_states[playlist_id] + + # Update the internal state - card handles its own visual appearance + card.set_phase(phase) + state['phase'] = phase + + # Clean up any existing status widgets for this playlist when changing phases + playlist_data = state.get('playlist_data') + if playlist_data and hasattr(playlist_data, 'id'): + if playlist_data.id in self.youtube_status_widgets: # Reuse existing status widget system + status_widget = self.youtube_status_widgets.pop(playlist_data.id, None) + if status_widget: + status_widget.setParent(None) + status_widget.deleteLater() + print(f"🧹 Cleaned up status widget for Tidal phase change to: {phase}") + + def update_tidal_card_playlist_info(self, playlist_id: str, name: str, track_count: int): + """Update Tidal card playlist information""" + if playlist_id in self.tidal_cards: + card = self.tidal_cards[playlist_id] + card.update_playlist_info(name, track_count) + + def set_tidal_card_playlist_data(self, playlist_id: str, playlist_data): + """Store playlist data for a Tidal card""" + if playlist_id in self.tidal_playlist_states: + self.tidal_playlist_states[playlist_id]['playlist_data'] = playlist_data + if hasattr(playlist_data, 'tracks'): + self.tidal_playlist_states[playlist_id]['discovered_tracks'] = playlist_data.tracks + + # Update card with playlist info + if playlist_id in self.tidal_cards: + card = self.tidal_cards[playlist_id] + card.playlist_data = playlist_data + card.discovered_tracks = playlist_data.tracks + + def get_tidal_playlist_state(self, playlist_id: str): + """Get the current state data for a Tidal playlist""" + return self.tidal_playlist_states.get(playlist_id, None) + + def reset_tidal_playlist_state(self, playlist_id: str): + """Reset Tidal playlist state (for cancel operations)""" + if playlist_id in self.tidal_playlist_states: + state = self.tidal_playlist_states[playlist_id] + state['phase'] = 'discovering' + state['playlist_data'] = None + state['discovered_tracks'] = [] + state['discovery_modal'] = None + state['download_modal'] = None + + # Reset card to initial state + if playlist_id in self.tidal_cards: + card = self.tidal_cards[playlist_id] + card.set_phase('discovering') + # Use original name instead of "Loading..." to keep playlist title visible + original_name = state.get('original_name', 'Unknown Playlist') + original_count = state.get('original_track_count', 0) + card.update_playlist_info(original_name, original_count) + card.update_progress(0, 0, 0) + + def remove_tidal_playlist_card(self, playlist_id: str): + """Remove a Tidal playlist card (for full cleanup)""" + if playlist_id in self.tidal_cards: + card = self.tidal_cards[playlist_id] + card.setParent(None) + del self.tidal_cards[playlist_id] + + if playlist_id in self.tidal_playlist_states: + del self.tidal_playlist_states[playlist_id] + def on_youtube_card_clicked(self, url: str, phase: str): """Handle YouTube playlist card clicks - route to appropriate modal""" print(f"🎬 YouTube card clicked: URL={url}, Phase={phase}") @@ -5517,6 +6504,273 @@ class OptimizedSpotifyDiscoveryWorker(QRunnable): print(f"❌ Error in retry with title+artist: {e}") return None +class TidalSpotifyDiscoveryWorkerSignals(QObject): + track_discovered = pyqtSignal(int, object, str) # row, spotify_track, status + progress_updated = pyqtSignal(int) # current progress + finished = pyqtSignal(int) # total successful discoveries + +class TidalSpotifyDiscoveryWorker(QRunnable): + def __init__(self, tidal_tracks, spotify_client, matching_engine): + super().__init__() + self.tidal_tracks = tidal_tracks + self.spotify_client = spotify_client + self.matching_engine = matching_engine + self.signals = TidalSpotifyDiscoveryWorkerSignals() + self.is_cancelled = False + + def cancel(self): + self.is_cancelled = True + + def run(self): + """Discover Spotify tracks for Tidal tracks with optimized timing""" + successful_discoveries = 0 + + for i, tidal_track in enumerate(self.tidal_tracks): + if self.is_cancelled: + break + + try: + # Create search query from Tidal track data + if tidal_track.artists: + query = f"{tidal_track.artists[0]} {tidal_track.name}" + else: + query = tidal_track.name + + # Debug logging for search queries + print(f"🔍 Spotify search query for Tidal track: '{query}' (track: '{tidal_track.name}', artist: '{tidal_track.artists[0] if tidal_track.artists else 'None'}')") + + # Search Spotify - get more results for validation + spotify_results = self.spotify_client.search_tracks(query, limit=10) + + # Progress tracking + if spotify_results: + print(f"📊 Found {len(spotify_results)} Spotify results for Tidal track:") + for idx, result in enumerate(spotify_results[:3]): # Show first 3 + print(f" {idx+1}. '{result.name}' by {', '.join(result.artists)}") + + if spotify_results: + # Use the matching engine to find the best match + best_track = self.find_best_validated_match(tidal_track, spotify_results) + + if not best_track: + # Try with swapped fields if no match found + print(f"🔄 No direct match found, trying swapped fields for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") + best_track = self.retry_with_swapped_fields(tidal_track) + + if best_track: + print(f"🔄 Found match after swapping artist/track for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") + else: + # Final fallback: try with cleaned data + best_track = self.retry_with_uncleaned_data(tidal_track) + + if best_track: + print(f"🔍 Found match with uncleaned data for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") + else: + # Last resort: try title+artist combo + best_track = self.retry_with_raw_title_and_artist(tidal_track) + + if best_track: + print(f"🎯 Found match with title+artist fallback for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") + + if best_track: + successful_discoveries += 1 + self.signals.track_discovered.emit(i, best_track, "found") + print(f"✅ Matched Tidal track '{tidal_track.name}' to Spotify track '{best_track.name}' by {', '.join(best_track.artists)}") + else: + self.signals.track_discovered.emit(i, None, "not_found") + print(f"❌ No Spotify match found for Tidal track '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") + else: + # No search results - try fallback approaches + best_track = self.retry_with_swapped_fields(tidal_track) + + if best_track: + print(f"🔄 Found match after swapping artist/track for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") + successful_discoveries += 1 + self.signals.track_discovered.emit(i, best_track, "found") + else: + # Try with uncleaned data + best_track = self.retry_with_uncleaned_data(tidal_track) + + if best_track: + print(f"🔍 Found match with uncleaned data for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") + successful_discoveries += 1 + self.signals.track_discovered.emit(i, best_track, "found") + else: + # Final fallback + best_track = self.retry_with_raw_title_and_artist(tidal_track) + + if best_track: + print(f"🎯 Found match with title+artist fallback for: '{tidal_track.name}' by '{tidal_track.artists[0] if tidal_track.artists else 'Unknown'}'") + successful_discoveries += 1 + self.signals.track_discovered.emit(i, best_track, "found") + else: + self.signals.track_discovered.emit(i, None, "not_found") + print(f"❌ No Spotify match found for Tidal track '{tidal_track.name}'") + + # Update progress + self.signals.progress_updated.emit(i + 1) + + # Brief pause to avoid overwhelming the API + time.sleep(0.25) # 250ms delay + + except Exception as e: + print(f"❌ Error processing Tidal track '{tidal_track.name}': {str(e)}") + self.signals.track_discovered.emit(i, None, "error") + continue + + print(f"🎵 Tidal discovery completed: {successful_discoveries} successful discoveries") + self.signals.finished.emit(successful_discoveries) + + def find_best_validated_match(self, tidal_track, spotify_results): + """Find the best validated match using the matching engine""" + if not spotify_results: + return None + + # Clean the Tidal track name for matching (similar to YouTube logic) + cleaned_tidal_name = self.clean_for_tidal_matching(tidal_track.name) + + # Create a fake track object that looks like a YouTube track for the matching engine + tidal_as_youtube = type('Track', (), { + 'name': cleaned_tidal_name, + 'artists': tidal_track.artists if tidal_track.artists else ["Unknown"], + 'album': getattr(tidal_track, 'album', 'Unknown Album'), + 'duration_ms': getattr(tidal_track, 'duration_ms', 0) + })() + + confidence_threshold = 0.7 + best_track = None + best_confidence = 0 + + # Test each Spotify result against the Tidal track + for spotify_track in spotify_results: + try: + # Use the matching engine to calculate confidence + cleaned_spotify_track = self.matching_engine.normalize_track_for_matching(spotify_track) + confidence = self.matching_engine.calculate_similarity_confidence( + tidal_as_youtube, cleaned_spotify_track + ) + + if confidence > best_confidence: + best_confidence = confidence + best_track = spotify_track + + print(f"🎯 Tidal->Spotify match confidence: {confidence:.3f} for '{spotify_track.name}' by {', '.join(spotify_track.artists)}") + + except Exception as e: + print(f"❌ Error validating match: {e}") + continue + + if best_confidence >= confidence_threshold: + print(f"✅ Best validated Tidal->Spotify match: '{best_track.name}' (confidence: {best_confidence:.3f})") + return best_track + else: + print(f"❌ Best Tidal->Spotify confidence {best_confidence:.3f} < {confidence_threshold}") + return None + + def clean_for_tidal_matching(self, title): + """Clean Tidal track title for better matching (similar to YouTube logic)""" + if not title: + return "" + + # Remove common Tidal-specific markers and clean the title + cleaned = title.lower() + cleaned = re.sub(r'\s*\(.*?\)\s*', ' ', cleaned) # Remove parenthetical content + cleaned = re.sub(r'\s*\[.*?\]\s*', ' ', cleaned) # Remove bracketed content + cleaned = re.sub(r'\s*-\s*remaster.*', ' ', cleaned, re.IGNORECASE) # Remove remaster info + cleaned = re.sub(r'\s*-\s*\d{4}.*', ' ', cleaned) # Remove year info + cleaned = re.sub(r'\s+', ' ', cleaned).strip() # Normalize whitespace + + return cleaned + + def retry_with_swapped_fields(self, tidal_track): + """Retry search with artist/track fields swapped""" + try: + if not tidal_track.artists or len(tidal_track.artists) == 0: + return None + + # Swap: use track name as artist and artist name as track + swapped_query = f"{tidal_track.name} {tidal_track.artists[0]}" + print(f"🔄 Trying swapped Tidal fields: '{swapped_query}'") + + spotify_results = self.spotify_client.search_tracks(swapped_query, limit=5) + if spotify_results: + return self.find_best_validated_match(tidal_track, spotify_results) + return None + except Exception as e: + print(f"❌ Error in swapped fields retry for Tidal track: {e}") + return None + + def retry_with_uncleaned_data(self, tidal_track): + """Retry with original uncleaned Tidal track data""" + try: + # Use original, uncleaned title and artist + if tidal_track.artists: + raw_query = f"{tidal_track.artists[0]} {tidal_track.name}" + else: + raw_query = tidal_track.name + + print(f"🔍 Trying uncleaned Tidal data: '{raw_query}'") + + spotify_results = self.spotify_client.search_tracks(raw_query, limit=5) + if spotify_results: + return self.find_best_validated_match(tidal_track, spotify_results) + return None + except Exception as e: + print(f"❌ Error in uncleaned data retry for Tidal track: {e}") + return None + + def retry_with_raw_title_and_artist(self, tidal_track): + """Final fallback: combine raw title and artist in one query""" + try: + if not tidal_track.artists: + return None + + # Combine everything into one search term + combined_query = f"{tidal_track.name} {tidal_track.artists[0]}" + print(f"🎯 Trying combined Tidal query: '{combined_query}'") + + spotify_results = self.spotify_client.search_tracks(combined_query, limit=5) + if spotify_results: + best_confidence = 0 + best_track = None + confidence_threshold = 0.6 # Lower threshold for final fallback + + for track in spotify_results: + try: + # Basic string similarity as last resort + track_similarity = self.basic_string_similarity( + f"{tidal_track.name} {tidal_track.artists[0]}".lower(), + f"{track.name} {' '.join(track.artists)}".lower() + ) + + if track_similarity > best_confidence: + best_confidence = track_similarity + best_track = track + except Exception as e: + continue + + if best_confidence >= confidence_threshold: + print(f"🎯 Title+artist fallback found match: confidence {best_confidence:.3f}") + return best_track + else: + print(f"❌ Title+artist fallback: Best confidence {best_confidence:.3f} < {confidence_threshold}") + else: + print(f"❌ No results found for title+artist query: '{combined_query}'") + + return None + + except Exception as e: + print(f"❌ Error in retry with title+artist for Tidal track: {e}") + return None + + def basic_string_similarity(self, s1, s2): + """Calculate basic string similarity for fallback matching""" + try: + from difflib import SequenceMatcher + return SequenceMatcher(None, s1, s2).ratio() + except: + return 0.0 + class SpotifyDiscoveryManagerSignals(QObject): track_discovered = pyqtSignal(int, object, str) # row, spotify_track, status progress_updated = pyqtSignal(int) # current progress @@ -7321,6 +8575,22 @@ class DownloadMissingTracksModal(QDialog): if self.playlist.id in self.parent_page.active_youtube_download_modals: del self.parent_page.active_youtube_download_modals[self.playlist.id] + # If this is a Tidal workflow, update card and clean up + if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): + # Update Tidal card to download_complete phase + if hasattr(self.parent_page, 'update_tidal_card_phase'): + self.parent_page.update_tidal_card_phase(self.playlist_id, 'download_complete') + + # Clean up download modal reference from Tidal state + if hasattr(self.parent_page, 'tidal_playlist_states') and self.playlist_id in self.parent_page.tidal_playlist_states: + state = self.parent_page.tidal_playlist_states[self.playlist_id] + if state.get('download_modal') == self: + state['download_modal'] = None + + # Remove from active download modals + if self.playlist.id in self.parent_page.active_youtube_download_modals: + del self.parent_page.active_youtube_download_modals[self.playlist.id] + # The process_finished signal is still emitted to unlock the main UI. self.process_finished.emit() @@ -7429,6 +8699,18 @@ class DownloadMissingTracksModal(QDialog): if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'update_youtube_card_phase'): print("🔄 Returning YouTube playlist to discovery_complete state") self.parent_page.update_youtube_card_phase(self.youtube_url, 'discovery_complete') + + # Handle Tidal playlist cancel - revert to discovery_complete phase + if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): + if hasattr(self.parent_page, 'update_tidal_card_phase'): + print("🔄 Returning Tidal playlist to discovery_complete state") + self.parent_page.update_tidal_card_phase(self.playlist_id, 'discovery_complete') + + # Clean up download modal reference from Tidal state + if hasattr(self.parent_page, 'tidal_playlist_states') and self.playlist_id in self.parent_page.tidal_playlist_states: + state = self.parent_page.tidal_playlist_states[self.playlist_id] + if state.get('download_modal') == self: + state['download_modal'] = None # Clean up this modal's reference. if self.playlist.id in self.parent_page.active_youtube_download_modals: @@ -7518,6 +8800,11 @@ class DownloadMissingTracksModal(QDialog): if self.is_youtube_workflow and hasattr(self, 'youtube_url'): if hasattr(self.parent_page, 'update_youtube_card_phase'): self.parent_page.update_youtube_card_phase(self.youtube_url, 'downloading') + + # Handle Tidal playlist downloading phase update + if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): + if hasattr(self.parent_page, 'update_tidal_card_phase'): + self.parent_page.update_tidal_card_phase(self.playlist_id, 'downloading') return print("No download in progress or cancel requested. Performing full cleanup.") @@ -7689,8 +8976,11 @@ class YouTubeDownloadMissingTracksModal(QDialog): self.start_spotify_discovery() def setup_ui(self): - """Set up the YouTube-specific modal UI""" - self.setWindowTitle(f"YouTube Playlist Discovery - {self.playlist.name}") + """Set up the modal UI for YouTube or Tidal playlist discovery""" + if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist: + self.setWindowTitle(f"Tidal Playlist Discovery - {self.playlist.name}") + else: + self.setWindowTitle(f"YouTube Playlist Discovery - {self.playlist.name}") self.resize(1400, 900) self.setWindowFlags(Qt.WindowType.Window) @@ -8031,11 +9321,21 @@ class YouTubeDownloadMissingTracksModal(QDialog): from core.matching_engine import MusicMatchingEngine matching_engine = MusicMatchingEngine() - self.spotify_worker = OptimizedSpotifyDiscoveryWorker( - self.playlist.tracks, - self.parent_page.spotify_client, - matching_engine - ) + # Use TidalSpotifyDiscoveryWorker if this is a Tidal playlist + if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist: + print("🎵 Using Tidal discovery worker for Tidal playlist") + self.spotify_worker = TidalSpotifyDiscoveryWorker( + self.playlist.tracks, + self.parent_page.spotify_client, + matching_engine + ) + else: + print("🎥 Using YouTube discovery worker for YouTube playlist") + self.spotify_worker = OptimizedSpotifyDiscoveryWorker( + self.playlist.tracks, + self.parent_page.spotify_client, + matching_engine + ) # Connect signals self.spotify_worker.signals.track_discovered.connect(self.on_track_discovered) @@ -8142,6 +9442,22 @@ class YouTubeDownloadMissingTracksModal(QDialog): print(f"🎵 Spotify discovery completed: {successful_discoveries}/{self.total_tracks} tracks found") + # Update card state for Tidal playlists (matches YouTube workflow) + if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): + print(f"🎵 Updating Tidal card state to discovery_complete for playlist_id: {self.playlist_id}") + if hasattr(self.parent_page, 'update_tidal_card_phase'): + self.parent_page.update_tidal_card_phase(self.playlist_id, 'discovery_complete') + + # Store playlist data in state for future modal reopening + if hasattr(self.parent_page, 'set_tidal_card_playlist_data'): + self.parent_page.set_tidal_card_playlist_data(self.playlist_id, self.playlist) + + # Update card state for YouTube playlists (existing logic) + if hasattr(self, 'youtube_url'): + print(f"🎬 Updating YouTube card state to discovery_complete for URL: {self.youtube_url}") + if hasattr(self.parent_page, 'update_youtube_card_phase'): + self.parent_page.update_youtube_card_phase(self.youtube_url, 'discovery_complete') + # Enable the Plex analysis and sync buttons self.begin_search_btn.setEnabled(True) self.begin_search_btn.setText(f"🔍 Download Missing Tracks ({successful_discoveries} tracks)") @@ -8187,7 +9503,7 @@ class YouTubeDownloadMissingTracksModal(QDialog): is_youtube_workflow=True # Flag to indicate this is from YouTube discovery ) - # Transfer URL tracking from discovery modal to download modal + # Transfer URL tracking from discovery modal to download modal (YouTube) if hasattr(self, 'youtube_url'): modal.youtube_url = self.youtube_url self.parent_page.active_youtube_processes[self.youtube_url] = modal @@ -8197,6 +9513,22 @@ class YouTubeDownloadMissingTracksModal(QDialog): if hasattr(self.parent_page, 'update_youtube_card_phase'): self.parent_page.update_youtube_card_phase(self.youtube_url, 'downloading') + # Transfer playlist tracking from discovery modal to download modal (Tidal) + if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): + modal.playlist_id = self.playlist_id + modal.is_tidal_playlist = True + modal.tidal_playlist = discovered_playlist + print(f"🔄 Transferred Tidal playlist tracking to download modal: {self.playlist_id}") + + # Update Tidal card to downloading phase + if hasattr(self.parent_page, 'update_tidal_card_phase'): + self.parent_page.update_tidal_card_phase(self.playlist_id, 'downloading') + + # Update Tidal state to link download modal + if hasattr(self.parent_page, 'tidal_playlist_states') and self.playlist_id in self.parent_page.tidal_playlist_states: + state = self.parent_page.tidal_playlist_states[self.playlist_id] + state['download_modal'] = modal + # Store the modal reference using the ID of the NEWLY created playlist object. print(f"📝 Storing modal with CORRECT discovered_playlist.id: {discovered_playlist.id}") self.parent_page.active_youtube_download_modals[discovered_playlist.id] = modal @@ -8311,6 +9643,12 @@ class YouTubeDownloadMissingTracksModal(QDialog): if hasattr(self, 'youtube_url') and hasattr(self.parent_page, 'reset_youtube_playlist_state'): self.parent_page.reset_youtube_playlist_state(self.youtube_url) + # Update Tidal card state - reset to initial discovering state for Cancel + if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and hasattr(self, 'playlist_id'): + if hasattr(self.parent_page, 'reset_tidal_playlist_state'): + print(f"🧹 Resetting Tidal playlist state to discovering on cancel for playlist_id: {self.playlist_id}") + self.parent_page.reset_tidal_playlist_state(self.playlist_id) + self.reject() def on_close_clicked(self): @@ -8461,7 +9799,10 @@ class YouTubeDownloadMissingTracksModal(QDialog): def show_loading_state(self): """Show loading state in the modal""" # Update window title - self.setWindowTitle("YouTube Playlist Discovery - Loading...") + if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist: + self.setWindowTitle("Tidal Playlist Discovery - Loading...") + else: + self.setWindowTitle("YouTube Playlist Discovery - Loading...") # Clear the table self.track_table.setRowCount(0) @@ -8497,7 +9838,10 @@ class YouTubeDownloadMissingTracksModal(QDialog): self.spotify_discovered_tracks = [None] * self.total_tracks # Update window title - self.setWindowTitle(f"YouTube Playlist Discovery - {playlist.name}") + if hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist: + self.setWindowTitle(f"Tidal Playlist Discovery - {playlist.name}") + else: + self.setWindowTitle(f"YouTube Playlist Discovery - {playlist.name}") # Update progress bars self.spotify_progress.setMaximum(self.total_tracks) @@ -8534,6 +9878,27 @@ class YouTubeDownloadMissingTracksModal(QDialog): print(f"🧹 Cleaning up URL tracking for: {self.youtube_url}") del self.parent_page.active_youtube_processes[self.youtube_url] + # Clean up Tidal playlist state when modal is actually closed (not just hidden) + # But don't clean up if we're transitioning to download modal + if (hasattr(self, 'is_tidal_playlist') and self.is_tidal_playlist and + hasattr(self, 'playlist_id') and hasattr(self.parent_page, 'tidal_playlist_states') and + not getattr(self, 'transitioning_to_download', False)): + + playlist_id = self.playlist_id + if playlist_id in self.parent_page.tidal_playlist_states: + state = self.parent_page.tidal_playlist_states[playlist_id] + + # Only clear the modal reference, don't reset the entire state + # This preserves discovery data for when user reopens the modal + if state.get('discovery_modal') == self: + print(f"🧹 Cleaning up Tidal discovery modal reference for playlist_id: {playlist_id}") + state['discovery_modal'] = None + + # If discovery was completed, keep the state, otherwise reset it + if state.get('phase') == 'discovering': + print(f"🧹 Discovery incomplete, resetting Tidal state for playlist_id: {playlist_id}") + self.parent_page.reset_tidal_playlist_state(playlist_id) + super().closeEvent(event)