Added tidal support. Fixed readme auth issues.
This commit is contained in:
parent
e84d2c9934
commit
ebc7e2246d
5 changed files with 2370 additions and 22 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
783
core/tidal_client.py
Normal file
783
core/tidal_client.py
Normal file
|
|
@ -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'<h1>Success!</h1><p>You can close this window and return to SoulSync.</p>')
|
||||
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'<h1>OAuth Error</h1><p>Error: {error}</p><p>Description: {error_description}</p>'.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'<h1>Error</h1><p>Authorization failed - no code received.</p>')
|
||||
|
||||
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
|
||||
|
|
@ -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'])
|
||||
|
||||
|
|
|
|||
1393
ui/pages/sync.py
1393
ui/pages/sync.py
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue