You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/core/tidal_client.py

2019 lines
85 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import os
import requests
import time
import re
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")
# Virtual playlist identity for the user's Favorite Tracks (Tidal's
# "My Collection" view). Treated like a normal playlist by every
# get_playlist consumer (mirror auto-refresh, discovery, sync UI) —
# the client recognizes the ID and dispatches to the dedicated
# `userCollectionTracks` endpoint internally. ID intentionally has no
# colon: the sync-services.js renderer interpolates IDs into CSS
# selectors via template literals (e.g. `#tidal-card-${p.id} .foo`)
# and a `:` in the ID would be parsed as a CSS pseudo-class operator.
COLLECTION_PLAYLIST_ID = "tidal-favorites"
COLLECTION_PLAYLIST_NAME = "Favorite Tracks"
COLLECTION_PLAYLIST_DESCRIPTION = "Your favorited tracks on Tidal"
# Global rate limiting variables
_last_api_call_time = 0
_api_call_lock = threading.Lock()
MIN_API_INTERVAL = 0.5 # 500ms between API calls
def rate_limited(func):
"""Decorator to enforce rate limiting on Tidal API calls with retry logic"""
@wraps(func)
def wrapper(*args, **kwargs):
max_retries = 4
last_exception = None
for attempt in range(max_retries):
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()
from core.api_call_tracker import api_call_tracker
api_call_tracker.record_call('tidal')
try:
result = func(*args, **kwargs)
return result
except Exception as e:
last_exception = e
error_str = str(e)
# Only retry on specific errors
if "rate limit" in error_str.lower() or "429" in error_str:
backoff = 3.0 * (2 ** attempt) # Exponential: 3s, 6s, 12s, 24s
logger.warning(f"Rate limit hit on attempt {attempt + 1}/{max_retries}, backing off {backoff}s: {e}")
if attempt < max_retries - 1:
time.sleep(backoff)
continue
elif "503" in error_str or "502" in error_str:
logger.warning(f"Tidal service error on attempt {attempt + 1}/{max_retries}, backing off: {e}")
if attempt < max_retries - 1:
time.sleep(2.0)
continue
# For other errors, don't retry
raise
# If we exhausted retries, raise the last exception
raise last_exception
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"
_tidal_port = int(os.environ.get('SOULSYNC_TIDAL_CALLBACK_PORT', 8889))
self.redirect_uri = f"http://127.0.0.1:{_tidal_port}/tidal/callback" # Default, will be updated from config
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')
self.redirect_uri = tidal_config.get('redirect_uri', self.redirect_uri) # Use config or default
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/vnd.api+json',
'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']
# Append version info (e.g. "Bloom remix") to title if present
track_title = attributes.get('title', 'Unknown Track')
track_version = attributes.get('version') or ''
if track_version and track_version.lower() not in track_title.lower():
track_title = f"{track_title} ({track_version})"
return Track(
id=str(track_id),
name=track_title,
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.
# `prompt=consent` forces Tidal to display the consent
# screen even when the app is already authorized — without
# it, re-authenticating with newly-added scopes (e.g.
# `collection.read` added in v2.5.0) can silently return a
# token carrying only the ORIGINAL scope set because Tidal
# treats the existing authorization as still valid.
params = {
'response_type': 'code',
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'scope': 'user.read playlists.read collection.read', # collection.read needed for userCollectionTracks endpoint
'code_challenge': self.code_challenge,
'code_challenge_method': 'S256',
'prompt': 'consent',
}
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"""
# Skip starting server in Docker/production mode - web server handles callbacks
import os
if os.getenv('FLASK_ENV') == 'production' or os.path.exists('/.dockerenv'):
logger.info("Docker/WebUI mode detected - skipping TidalClient callback server (web server handles callbacks)")
return
# 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 = int(os.environ.get('SOULSYNC_TIDAL_CALLBACK_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,
headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
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
if not self.client_id or not self.client_secret:
logger.debug("Tidal client_id/secret not configured — skipping token refresh")
# Clear stale tokens so we stop retrying
self.access_token = None
self.refresh_token = None
self.token_expires_at = 0
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,
headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
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 fetch_token_from_code(self, auth_code: str) -> bool:
"""Exchange authorization code for access tokens (for web server callback)"""
try:
logger.info(f"Starting token exchange with code: {auth_code[:20]}...")
logger.info(f"Using code_verifier: {self.code_verifier[:20] if self.code_verifier else 'None'}...")
logger.info(f"Using redirect_uri: {self.redirect_uri}")
self.auth_code = auth_code
result = self._exchange_code_for_tokens()
if result:
logger.info("Token exchange successful")
else:
logger.error("Token exchange failed")
return result
except Exception as e:
logger.error(f"Error in fetch_token_from_code: {e}")
import traceback
logger.error(f"Full traceback: {traceback.format_exc()}")
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, refreshing expired tokens if possible"""
if self.access_token and time.time() < self.token_expires_at:
return True
# Backoff: if refresh recently failed, don't retry for 5 minutes
if hasattr(self, '_refresh_failed_at') and self._refresh_failed_at:
if time.time() - self._refresh_failed_at < 300:
return False
# Token expired but refresh token available — try silent refresh
if self.access_token and self.refresh_token:
logger.info("Tidal access token expired — attempting silent refresh...")
result = self._refresh_access_token()
if not result:
self._refresh_failed_at = time.time()
return result
return False
def disconnect(self):
"""Clear all saved Tidal auth state so the next OAuth flow
starts fresh with the current scope set.
Used when a previously-authorized token doesn't carry a newly-
added scope (e.g. `collection.read`): even with `prompt=consent`
on the auth URL, some users hit a Tidal flow that rebinds the
existing grant. Disconnect first → re-authenticate forces a
clean slate."""
self.access_token = None
self.refresh_token = None
self.token_expires_at = 0
self._collection_needs_reconnect = False
self.session.headers.pop('Authorization', None)
try:
config_manager.set('tidal_tokens', {})
except Exception as e:
logger.warning(f"Failed to clear tidal_tokens config: {e}")
logger.info("Tidal client disconnected — saved tokens cleared")
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.
# NOTE: We don't include 'items' here because the V2 API only includes ~20 tracks
# We'll fetch full track lists separately for each playlist
endpoint = f"{self.base_url}/playlists"
params = {
'countryCode': 'US',
'filter[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: Process the playlists from the main 'data' array.
# Only extract metadata — tracks are fetched on-demand when the user
# selects a playlist to sync/mirror, not during the listing step.
for playlist_data in data.get('data', []):
attributes = playlist_data.get('attributes', {})
playlist_id = playlist_data.get('id')
# Extract image URL from relationships if available
image_url = None
try:
relationships = playlist_data.get('relationships', {})
image_rel = relationships.get('image', {}).get('data', {})
if image_rel:
# Image URL may be in included resources or constructed from ID
image_id = image_rel.get('id', '')
if image_id:
image_url = f"https://resources.tidal.com/images/{image_id.replace('-', '/')}/640x640.jpg"
except Exception as _e:
logger.debug("tidal v2 playlists image_url extract: %s", _e)
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',
tracks=[], # Empty — fetched on-demand via get_playlist()
)
# Store track count from metadata (no API call needed)
# V2 API may use different field names depending on version
new_playlist.track_count = (
attributes.get('numberOfTracks') or
attributes.get('numberOfItems') or
attributes.get('totalNumberOfItems') or
attributes.get('nrOfTracks') or
0
)
if image_url:
new_playlist.image_url = image_url
playlists.append(new_playlist)
logger.info(f"Successfully retrieved {len(playlists)} playlists (metadata only) 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 []
from urllib.parse import quote
encoded_query = quote(query, safe='')
params = {
'countryCode': 'US',
'include': 'tracks',
'limit': limit
}
response = self.session.get(
f"{self.base_url}/searchResults/{encoded_query}",
params=params,
timeout=10
)
if response.status_code == 429:
raise Exception("Rate limited (429) on search_tracks")
if response.status_code == 200:
data = response.json()
tracks = []
# Handle V2 JSON:API response formats
items = []
if 'tracks' in data and isinstance(data['tracks'], list):
items = data['tracks']
elif 'tracks' in data and 'items' in data['tracks']:
items = data['tracks']['items']
elif 'included' in data:
items = [r for r in data['included'] if r.get('type') == 'tracks']
for item in items:
# Flatten JSON:API resource if needed
if 'attributes' in item and 'id' in item:
flat = dict(item['attributes'])
flat['id'] = item['id']
item = flat
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:
if "429" in str(e):
raise # Let rate_limited decorator handle retry
logger.error(f"Error searching Tidal tracks: {e}")
return []
# ── Enrichment API Methods ──
@rate_limited
def search_artist(self, name: str) -> Optional[Dict]:
"""Search for an artist by name. Returns best matching result as raw dict or None."""
try:
if not self._ensure_valid_token():
return None
from urllib.parse import quote
from difflib import SequenceMatcher
encoded_query = quote(name, safe='')
params = {
'countryCode': 'US',
'include': 'artists',
}
response = self.session.get(
f"{self.base_url}/searchResults/{encoded_query}",
params=params,
timeout=10
)
if response.status_code == 429:
raise Exception("Rate limited (429) on search_artist")
if response.status_code == 200:
data = response.json()
# JSON:API format: included artists in 'artists' or nested in relationships
items = []
if 'artists' in data and isinstance(data['artists'], list):
items = data['artists']
elif 'artists' in data and 'items' in data['artists']:
items = data['artists']['items']
elif 'included' in data:
items = [r for r in data['included'] if r.get('type') == 'artists']
if items:
# Flatten all items and pick best name match
best_item = None
best_score = 0.0
for item in items:
if 'attributes' in item and 'id' in item:
flat = dict(item['attributes'])
flat['id'] = item['id']
else:
flat = item
item_name = flat.get('name', '')
score = SequenceMatcher(None, name.lower(), item_name.lower()).ratio()
if score > best_score:
best_score = score
best_item = flat
return best_item
else:
logger.debug(f"Tidal artist search failed: {response.status_code}")
return None
except Exception as e:
if "429" in str(e):
raise # Let rate_limited decorator handle retry
logger.error(f"Error searching Tidal artist: {e}")
return None
@rate_limited
def search_album(self, artist: str, title: str) -> Optional[Dict]:
"""Search for an album by artist + title. Returns first result as raw dict or None."""
try:
if not self._ensure_valid_token():
return None
from urllib.parse import quote
query = f"{artist} {title}" if artist else title
encoded_query = quote(query, safe='')
params = {
'countryCode': 'US',
'include': 'albums',
}
response = self.session.get(
f"{self.base_url}/searchResults/{encoded_query}",
params=params,
timeout=10
)
if response.status_code == 429:
raise Exception("Rate limited (429) on search_album")
if response.status_code == 200:
data = response.json()
items = []
if 'albums' in data and isinstance(data['albums'], list):
items = data['albums']
elif 'albums' in data and 'items' in data['albums']:
items = data['albums']['items']
elif 'included' in data:
items = [r for r in data['included'] if r.get('type') == 'albums']
if items:
# Flatten all items and pick best title match
from difflib import SequenceMatcher
best_item = None
best_score = 0.0
for item in items:
if 'attributes' in item and 'id' in item:
flat = dict(item['attributes'])
flat['id'] = item['id']
# Preserve artist relationship for cross-verification
try:
rel_artists = item.get('relationships', {}).get('artists', {}).get('data', [])
if rel_artists:
flat['artist'] = {'id': rel_artists[0].get('id')}
except (AttributeError, IndexError, TypeError):
pass
else:
flat = item
item_title = flat.get('title', '')
score = SequenceMatcher(None, title.lower(), item_title.lower()).ratio()
if score > best_score:
best_score = score
best_item = flat
return best_item
else:
logger.debug(f"Tidal album search failed: {response.status_code}")
return None
except Exception as e:
if "429" in str(e):
raise # Let rate_limited decorator handle retry
logger.error(f"Error searching Tidal album: {e}")
return None
@rate_limited
def search_track(self, artist: str, title: str) -> Optional[Dict]:
"""Search for a track by artist + title. Returns first result as raw dict or None."""
try:
if not self._ensure_valid_token():
return None
from urllib.parse import quote
query = f"{artist} {title}" if artist else title
encoded_query = quote(query, safe='')
params = {
'countryCode': 'US',
'include': 'tracks',
}
response = self.session.get(
f"{self.base_url}/searchResults/{encoded_query}",
params=params,
timeout=10
)
if response.status_code == 429:
raise Exception("Rate limited (429) on search_track")
if response.status_code == 200:
data = response.json()
items = []
if 'tracks' in data and isinstance(data['tracks'], list):
items = data['tracks']
elif 'tracks' in data and 'items' in data['tracks']:
items = data['tracks']['items']
elif 'included' in data:
items = [r for r in data['included'] if r.get('type') == 'tracks']
if items:
# Flatten all items and pick best title match
from difflib import SequenceMatcher
best_item = None
best_score = 0.0
for item in items:
if 'attributes' in item and 'id' in item:
flat = dict(item['attributes'])
flat['id'] = item['id']
# Preserve artist relationship for cross-verification
try:
rel_artists = item.get('relationships', {}).get('artists', {}).get('data', [])
if rel_artists:
flat['artist'] = {'id': rel_artists[0].get('id')}
except (AttributeError, IndexError, TypeError):
pass
else:
flat = item
item_title = flat.get('title', '')
score = SequenceMatcher(None, title.lower(), item_title.lower()).ratio()
if score > best_score:
best_score = score
best_item = flat
return best_item
else:
logger.debug(f"Tidal track search failed: {response.status_code}")
return None
except Exception as e:
if "429" in str(e):
raise # Let rate_limited decorator handle retry
logger.error(f"Error searching Tidal track: {e}")
return None
@rate_limited
def get_artist(self, artist_id: str) -> Optional[Dict]:
"""Get full artist details by Tidal ID."""
try:
if not self._ensure_valid_token():
return None
response = self.session.get(
f"{self.base_url}/artists/{artist_id}",
params={'countryCode': 'US'},
headers={'accept': 'application/vnd.api+json'},
timeout=10
)
if response.status_code == 429:
raise Exception("Rate limited (429) on get_artist")
if response.status_code == 200:
data = response.json()
# Handle JSON:API format
if 'data' in data and 'attributes' in data.get('data', {}):
result = dict(data['data'].get('attributes', {}))
result['id'] = data['data'].get('id', artist_id)
return result
return data
else:
logger.debug(f"Tidal get_artist failed: {response.status_code}")
return None
except Exception as e:
if "429" in str(e):
raise # Let rate_limited decorator handle retry
logger.error(f"Error getting Tidal artist {artist_id}: {e}")
return None
@rate_limited
def get_album(self, album_id: str) -> Optional[Dict]:
"""Get full album details by Tidal ID."""
try:
if not self._ensure_valid_token():
return None
response = self.session.get(
f"{self.base_url}/albums/{album_id}",
params={'countryCode': 'US'},
headers={'accept': 'application/vnd.api+json'},
timeout=10
)
if response.status_code == 429:
raise Exception("Rate limited (429) on get_album")
if response.status_code == 200:
data = response.json()
if 'data' in data and 'attributes' in data.get('data', {}):
result = dict(data['data'].get('attributes', {}))
result['id'] = data['data'].get('id', album_id)
return result
return data
else:
logger.debug(f"Tidal get_album failed: {response.status_code}")
return None
except Exception as e:
if "429" in str(e):
raise # Let rate_limited decorator handle retry
logger.error(f"Error getting Tidal album {album_id}: {e}")
return None
@rate_limited
def get_album_tracks(self, album_id: str, limit: Optional[int] = None) -> List[Track]:
"""Fetch every track on an album with full artist + name + duration
metadata hydrated.
Two-phase: walk `/v2/albums/{id}/relationships/items?include=items`
cursor chain to enumerate track IDs (with their position metadata —
`meta.trackNumber` + `meta.volumeNumber` for multi-disc), then
feed the IDs through the existing `_get_tracks_batch` helper for
artist + album-name resolution.
Returns a list of `Track` dataclasses with `track_number` and
`disc_number` attached as ad-hoc attributes so callers that need
per-position info (download modal, virtual playlist build) can
read them. Backend `/api/discover/album/<source>/<album_id>`
serializes these to the same shape Spotify/Deezer return."""
if not self._ensure_valid_token():
return []
# Phase 1: enumerate track IDs + position metadata via cursor pagination.
# The relationship endpoint pages at 20 items by default. The `meta`
# dict on each ref carries `trackNumber` + `volumeNumber` (multi-disc).
track_meta_by_id: Dict[str, Dict[str, int]] = {}
track_ids: List[str] = []
next_path: Optional[str] = None
while True:
if next_path:
url = (next_path if next_path.startswith('http')
else f"https://openapi.tidal.com/v2{next_path}")
params = None
else:
url = f"{self.base_url}/albums/{album_id}/relationships/items"
params = {'countryCode': 'US', 'include': 'items'}
try:
resp = self.session.get(
url, params=params,
headers={'accept': 'application/vnd.api+json'},
timeout=15,
)
except Exception as e:
logger.debug(f"Tidal album-tracks page request failed: {e}")
break
if resp.status_code != 200:
if resp.status_code == 429:
raise Exception("Rate limited (429) on get_album_tracks")
logger.debug(
f"Tidal album-tracks page returned {resp.status_code}: {resp.text[:200]}"
)
break
try:
data = resp.json()
except ValueError:
break
for item in data.get('data', []):
if item.get('type') != 'tracks':
continue
tid = item.get('id')
if not tid:
continue
tid = str(tid)
meta = item.get('meta', {}) or {}
track_meta_by_id[tid] = {
'track_number': int(meta.get('trackNumber') or 0),
'disc_number': int(meta.get('volumeNumber') or 1),
}
track_ids.append(tid)
if limit is not None and len(track_ids) >= limit:
break
if limit is not None and len(track_ids) >= limit:
break
next_path = data.get('links', {}).get('next')
if not next_path:
break
time.sleep(0.3)
if not track_ids:
return []
# Phase 2: batch hydrate via existing helper (artists + album names).
# Annotate each Track with position metadata so callers can build the
# per-track-number payload the download pipeline expects.
hydrated: List[Track] = []
for i in range(0, len(track_ids), self._COLLECTION_BATCH_SIZE):
batch_ids = track_ids[i:i + self._COLLECTION_BATCH_SIZE]
try:
batch_tracks = self._get_tracks_batch(batch_ids)
except Exception as e:
logger.debug(f"Tidal album-tracks batch hydration failed: {e}")
continue
for t in batch_tracks:
meta = track_meta_by_id.get(str(t.id), {})
t.track_number = meta.get('track_number', 0)
t.disc_number = meta.get('disc_number', 1)
hydrated.append(t)
# Tidal's relationship walk returns tracks in album order; the
# batch endpoint may not preserve order. Sort by (disc, track)
# so the modal renders the album top-down.
hydrated.sort(key=lambda t: (
getattr(t, 'disc_number', 1),
getattr(t, 'track_number', 0),
))
logger.info(
f"Retrieved {len(hydrated)}/{len(track_ids)} tracks for Tidal album {album_id}"
)
return hydrated
@rate_limited
def get_track(self, track_id: str) -> Optional[Dict]:
"""Get full track details by Tidal ID."""
try:
if not self._ensure_valid_token():
return None
response = self.session.get(
f"{self.base_url}/tracks/{track_id}",
params={'countryCode': 'US'},
headers={'accept': 'application/vnd.api+json'},
timeout=10
)
if response.status_code == 429:
raise Exception("Rate limited (429) on get_track")
if response.status_code == 200:
data = response.json()
if 'data' in data and 'attributes' in data.get('data', {}):
result = dict(data['data'].get('attributes', {}))
result['id'] = data['data'].get('id', track_id)
return result
return data
else:
logger.debug(f"Tidal get_track failed: {response.status_code}")
return None
except Exception as e:
if "429" in str(e):
raise # Let rate_limited decorator handle retry
logger.error(f"Error getting Tidal track {track_id}: {e}")
return None
@rate_limited
def get_playlist(self, playlist_id: str) -> Optional[Playlist]:
"""Get playlist details including tracks using JSON:API format.
Recognizes the virtual ``tidal-favorites`` ID and dispatches
to ``get_collection_tracks`` so every caller that already
accepts a playlist ID (mirror auto-refresh, discovery start,
per-playlist detail endpoint) gets Favorite Tracks support
for free without per-site special-casing.
ID intentionally has no colon — the sync-services.js renderer
builds CSS selectors via template literal interpolation
(``#tidal-card-${p.id} .playlist-card-track-count``) and a
``:`` in the ID would be parsed as a CSS pseudo-class operator.
"""
try:
if playlist_id == COLLECTION_PLAYLIST_ID:
collection_tracks = self.get_collection_tracks()
return Playlist(
id=COLLECTION_PLAYLIST_ID,
name=COLLECTION_PLAYLIST_NAME,
description=COLLECTION_PLAYLIST_DESCRIPTION,
tracks=collection_tracks,
owner={'name': 'You'},
public=False,
)
if not self._ensure_valid_token():
logger.error("Not authenticated with Tidal")
return None
# Get playlist metadata with JSON:API format
headers = {'accept': 'application/vnd.api+json'}
response = self.session.get(
f"{self.base_url}/playlists/{playlist_id}",
params={'countryCode': 'US'},
headers=headers,
timeout=10
)
response.raise_for_status()
if response.status_code != 200:
logger.error(f"Failed to get Tidal playlist {playlist_id}: {response.status_code} - {response.text}")
return None
# Parse JSON:API response structure
playlist_data = response.json().get("data", {})
playlist_attrs = playlist_data.get("attributes", {})
# Get playlist tracks with cursor-based pagination
tracks = []
cursor = None
total_fetched = 0
page_num = 0
consecutive_failures = 0
MAX_PAGE_RETRIES = 3
while True:
page_num += 1
# Rate limit between pagination pages (skip first page)
if page_num > 1:
time.sleep(1.0)
# Fetch a page of track IDs
try:
tracks_page = self._get_playlist_tracks_page(playlist_id, cursor)
except Exception as e:
error_str = str(e)
if "429" in error_str or "rate limit" in error_str.lower():
consecutive_failures += 1
if consecutive_failures <= MAX_PAGE_RETRIES:
backoff = 10.0 * consecutive_failures # 10s, 20s, 30s
logger.warning(f"Playlist pagination rate limited on page {page_num}, waiting {backoff}s (attempt {consecutive_failures}/{MAX_PAGE_RETRIES})")
time.sleep(backoff)
page_num -= 1 # Retry same page
continue
else:
logger.error(f"Playlist pagination failed after {MAX_PAGE_RETRIES} retries, returning {total_fetched} tracks fetched so far")
break
else:
logger.error(f"Error fetching playlist page {page_num}: {e}")
break
if not tracks_page or not tracks_page.get("data"):
logger.info("No more tracks found, stopping pagination")
break
# Reset failure counter on success
consecutive_failures = 0
# Extract track IDs from this page
track_ids = []
for item in tracks_page.get("data", []):
# In JSON:API, relationship items have both 'type' and 'id'
# The type should be 'tracks' but we'll be defensive
if item.get("type") and item.get("id"):
track_ids.append(item.get("id"))
if track_ids:
# Batch fetch full track details with artists and albums
try:
batch_tracks = self._get_tracks_batch(track_ids)
except Exception as e:
logger.error(f"Error fetching track details for page {page_num}: {e}")
# Continue pagination — we lose this batch but can still get remaining
batch_tracks = []
if len(batch_tracks) < len(track_ids):
logger.warning(f"Page {page_num}: requested {len(track_ids)} tracks but only {len(batch_tracks)} returned (some may be unavailable in your region)")
tracks.extend(batch_tracks)
total_fetched += len(batch_tracks)
logger.info(f"Fetched {len(batch_tracks)} tracks in this batch, {total_fetched} total so far")
# Get next cursor from Tidal's response
# Tidal uses: links.meta.nextCursor (confirmed by PR #113)
cursor = tracks_page.get("links", {}).get("meta", {}).get("nextCursor")
# If no cursor found, pagination is complete
if not cursor:
logger.info("No next cursor found, pagination complete")
break
playlist = Playlist(
id=playlist_data.get('id', playlist_id),
name=playlist_attrs.get('name', 'Unknown Playlist'),
description=playlist_attrs.get('description', ''),
tracks=tracks,
external_urls={'tidal': f"https://listen.tidal.com/playlist/{playlist_id}"},
public=playlist_attrs.get('accessType', '') == "PUBLIC"
)
# Extract cover image URL from relationships (same logic as get_user_playlists_metadata_only)
try:
relationships = playlist_data.get('relationships', {})
image_rel = relationships.get('image', {}).get('data', {})
if image_rel:
image_id = image_rel.get('id', '')
if image_id:
playlist.image_url = f"https://resources.tidal.com/images/{image_id.replace('-', '/')}/640x640.jpg"
except Exception as _e:
logger.debug("tidal playlist image_url extract: %s", _e)
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
@rate_limited
def _get_playlist_tracks_page(self, playlist_id: str, cursor: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Fetch a page of track IDs from a playlist using cursor-based pagination"""
try:
params = {"countryCode": "US"}
if cursor:
params["page[cursor]"] = cursor
headers = {'accept': 'application/vnd.api+json'}
response = self.session.get(
f"{self.base_url}/playlists/{playlist_id}/relationships/items",
params=params,
headers=headers,
timeout=10
)
response.raise_for_status()
if response.status_code != 200:
logger.error(f"Failed to get playlist tracks page: {response.status_code} - {response.text}")
return None
return response.json()
except requests.exceptions.HTTPError:
raise # Let HTTP errors (429, 503, etc.) propagate to rate_limited decorator for retry
except Exception as e:
logger.error(f"Error fetching playlist tracks page: {e}")
return None
@rate_limited
def _get_tracks_batch(self, track_ids: List[str]) -> List[Track]:
"""Batch fetch track details with artists and albums included"""
try:
if not track_ids:
return []
params = {
"countryCode": "US",
"include": "artists,albums",
"filter[id]": ",".join(track_ids)
}
headers = {'accept': 'application/vnd.api+json'}
response = self.session.get(
f"{self.base_url}/tracks",
params=params,
headers=headers,
timeout=10
)
response.raise_for_status()
if response.status_code != 200:
logger.error(f"Failed to get tracks batch: {response.status_code} - {response.text}")
return []
tracks_data = response.json()
# Build lookup caches for albums and artists from included data
album_cache: Dict[str, str] = {}
artist_cache: Dict[str, str] = {}
for item in tracks_data.get("included", []):
item_id = item.get("id")
item_type = item.get("type")
if item_type == "albums":
album_cache[item_id] = item.get("attributes", {}).get("title", "Unknown Album")
elif item_type == "artists":
artist_cache[item_id] = item.get("attributes", {}).get("name", "Unknown Artist")
# Parse tracks and hydrate with artist/album data
hydrated_tracks: List[Track] = []
for track_data in tracks_data.get("data", []):
attrs = track_data.get("attributes", {})
track_id = track_data.get("id")
relationships = track_data.get("relationships", {})
# Get album name from cache
album_data = relationships.get("albums", {}).get("data", [])
album_id = album_data[0].get("id") if album_data else None
album = album_cache.get(album_id, "Unknown Album")
# Get artist names from cache
artist_data_list = relationships.get("artists", {}).get("data", [])
artists = [
artist_cache.get(artist_ref.get("id"), "Unknown Artist")
for artist_ref in artist_data_list
if artist_ref.get("id")
]
if not artists:
artists = ["Unknown Artist"]
# Parse duration (ISO-8601 format like 'PT3M36S')
duration_ms = self._parse_iso_duration(attrs.get('duration', ''))
# Append version info (e.g. "BMotion Remix") to title if present
track_title = attrs.get('title', 'Unknown Track')
track_version = attrs.get('version') or ''
if track_version and track_version.lower() not in track_title.lower():
track_title = f"{track_title} ({track_version})"
hydrated_tracks.append(Track(
id=str(track_id),
name=track_title,
artists=artists,
album=album,
duration_ms=duration_ms,
external_urls={'tidal': f"https://listen.tidal.com/track/{track_id}"},
explicit=attrs.get('explicit', False)
))
return hydrated_tracks
except requests.exceptions.HTTPError:
raise # Let HTTP errors (429, 503, etc.) propagate to rate_limited decorator for retry
except Exception as e:
logger.error(f"Error getting tracks batch: {e}")
return []
def _parse_iso_duration(self, duration: str) -> int:
"""Convert ISO-8601 duration string (e.g., 'PT3M36S' or 'PT1H30M45S') to milliseconds"""
if not duration or not duration.startswith("PT"):
return 0
total_seconds = 0
# Extract hours, minutes, and seconds using regex
hours_match = re.search(r"(\d+)H", duration)
minutes_match = re.search(r"(\d+)M", duration)
seconds_match = re.search(r"(\d+)S", duration)
if hours_match:
total_seconds += int(hours_match.group(1)) * 3600
if minutes_match:
total_seconds += int(minutes_match.group(1)) * 60
if seconds_match:
total_seconds += int(seconds_match.group(1))
return total_seconds * 1000
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')]
# Append version info (e.g. "Bloom remix") to title if present
track_title = item.get('title', 'Unknown Track')
track_version = item.get('version') or ''
if track_version and track_version.lower() not in track_title.lower():
track_title = f"{track_title} ({track_version})"
track = Track(
id=str(track_id),
name=track_title,
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
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
# `get_favorite_artists` and `get_favorite_albums` were defined here
# against the legacy `/v2/favorites?filter[type]=...` endpoint with a
# V1 fallback. Both paths are dead in 2026: V2 returns 404 for
# personal favorites (it's scoped to third-party-app-created
# collections only), and V1 returns 403 because modern OAuth tokens
# carry `collection.read` instead of the legacy `r_usr` scope V1
# demands. Replaced by the V2 user-collection endpoints below — see
# the "Favorited albums + artists" section near the end of this class.
# ------------------------------------------------------------------
# User Collection ("Favorite Tracks" — Tidal calls this "My Collection")
# ------------------------------------------------------------------
#
# Tidal V2 exposes the user's favorited tracks via a separate
# cursor-paginated endpoint:
#
# GET /v2/userCollectionTracks/me/relationships/items
# ?countryCode=US&locale=en-US&include=items
#
# Each page returns up to 20 entries in `data[]` (track refs with
# `id` + `type='tracks'` + `meta.addedAt`) and an OPTIONAL `links.next`
# URL for the next page. The included track resources only carry the
# track-level attributes (title, isrc, duration, mediaTags) — artists
# and album NAMES come back as relationship-link stubs only, not
# embedded data.
#
# We split the work in two phases:
# 1) `_iter_collection_track_ids()` — enumerates every collection
# entry by following the cursor chain. Cheap (just IDs + types).
# 2) Hydration — feeds the IDs through the existing
# `_get_tracks_batch` helper which already knows how to
# `include=artists,albums` and produce fully-populated `Track`
# objects matching the rest of the codebase. No new parsing,
# no new dataclass shape, no duplication of the JSON:API parse.
#
# Reference: https://github.com/Nezreka/SoulSync/issues/502 — reporter
# Yug1900 located the working endpoint after the prior `/v2/favorites`
# filter approach returned empty data for personal favorites.
#
# Auth state — calling code (web_server.py listing endpoint) needs
# to know whether an empty result means "user has no favorites" or
# "token lacks `collection.read` scope and needs reconnect". The
# iter helper sets `self._collection_needs_reconnect = True` when
# the endpoint returns 401/403 so the listing endpoint can surface
# a user-actionable hint instead of silently hiding the row.
_COLLECTION_TRACKS_PATH = "userCollectionTracks/me/relationships/items"
_COLLECTION_ALBUMS_PATH = "userCollectionAlbums/me/relationships/items"
_COLLECTION_ARTISTS_PATH = "userCollectionArtists/me/relationships/items"
_COLLECTION_BATCH_SIZE = 20 # Tidal `filter[id]` page cap
@rate_limited
def _iter_collection_resource_ids(self, path: str, expected_type: str,
max_ids: Optional[int] = None) -> List[str]:
"""Walk a cursor-paginated collection endpoint and return the
list of resource IDs (tracks / albums / artists).
Generic across all three favorited-resource endpoints — the
only differences between them are the path segment and the
``type`` field on each ``data[]`` entry. Pagination, auth,
scope-failure detection, and the diagnostic logging are
identical.
``max_ids`` caps the walk early. Returns ``[]`` when not
authenticated or when the endpoint refuses (e.g. token without
``collection.read`` scope). On 401/403 also flips
``self._collection_needs_reconnect = True`` so the caller can
distinguish 'empty collection' from 'missing scope'."""
# Reset on every call so a successful walk clears any stale flag
# left from a prior failed attempt.
self._collection_needs_reconnect = False
if not self._ensure_valid_token():
logger.debug("Tidal not authenticated — cannot fetch collection %s", expected_type)
return []
ids: List[str] = []
next_path: Optional[str] = None
while True:
if next_path:
# `links.next` from Tidal is path-relative to /v2 root,
# already carries every query param we need (cursor + countryCode + locale + include).
url = next_path if next_path.startswith('http') else f"https://openapi.tidal.com/v2{next_path}"
params = None
else:
url = f"{self.base_url}/{path}"
params = {
'countryCode': 'US',
'locale': 'en-US',
'include': 'items',
}
try:
headers = {
'accept': 'application/vnd.api+json',
'Authorization': f'Bearer {self.access_token}',
}
logger.info(
f"Tidal collection request: GET {url} (params={params})"
)
resp = requests.get(url, params=params, headers=headers, timeout=15)
logger.info(
f"Tidal collection response: status={resp.status_code} "
f"body[:300]={resp.text[:300]!r}"
)
except Exception as e:
logger.warning(f"Tidal collection page request failed: {e}")
break
if resp.status_code != 200:
# 401/403 = scope/permission issue. Token predates the
# `collection.read` scope expansion or the user revoked
# it — flag for the UI hint and bail.
if resp.status_code in (401, 403):
self._collection_needs_reconnect = True
logger.info(
"Tidal collection endpoint returned %s — reconnect Tidal "
"in Settings → Connections to grant `collection.read` scope.",
resp.status_code,
)
else:
logger.warning(
f"Tidal collection page returned {resp.status_code}: {resp.text[:500]}"
)
break
try:
data = resp.json()
except ValueError as e:
logger.debug(f"Tidal collection response not JSON: {e}")
break
for item in data.get('data', []):
if item.get('type') != expected_type:
continue
rid = item.get('id')
if rid:
ids.append(str(rid))
if max_ids is not None and len(ids) >= max_ids:
return ids
next_path = data.get('links', {}).get('next')
if not next_path:
break
time.sleep(0.3) # Cursor pagination courtesy delay
return ids
def _iter_collection_track_ids(self, max_ids: Optional[int] = None) -> List[str]:
"""Favorited tracks — thin wrapper over the generic walker."""
return self._iter_collection_resource_ids(
self._COLLECTION_TRACKS_PATH, 'tracks', max_ids,
)
def _iter_collection_album_ids(self, max_ids: Optional[int] = None) -> List[str]:
"""Favorited albums — thin wrapper over the generic walker."""
return self._iter_collection_resource_ids(
self._COLLECTION_ALBUMS_PATH, 'albums', max_ids,
)
def _iter_collection_artist_ids(self, max_ids: Optional[int] = None) -> List[str]:
"""Favorited artists — thin wrapper over the generic walker."""
return self._iter_collection_resource_ids(
self._COLLECTION_ARTISTS_PATH, 'artists', max_ids,
)
def collection_needs_reconnect(self) -> bool:
"""True when the most recent collection fetch hit a 401/403 —
i.e. the saved token doesn't have `collection.read` scope and
the user needs to reconnect Tidal in Settings → Connections.
Reset to False at the start of every `_iter_collection_track_ids`
call so a successful walk clears stale flags."""
return getattr(self, '_collection_needs_reconnect', False)
def get_collection_tracks_count(self) -> int:
"""Total count of tracks in the user's Favorite Tracks.
Tidal's cursor pagination doesn't expose a `meta.total` so we
walk the full ID list. Cheap relative to hydration (one small
request per 20 tracks, no per-track lookups), but linear in
collection size — call sparingly."""
try:
return len(self._iter_collection_track_ids())
except Exception as e:
logger.debug(f"Failed to get Tidal collection tracks count: {e}")
return 0
def get_collection_tracks(self, limit: Optional[int] = None) -> List[Track]:
"""Fetch user's favorited tracks with full artist + album
metadata hydrated via `_get_tracks_batch`.
Returns the same `Track` shape every other Tidal playlist
method returns, so the virtual-playlist plumbing in
`web_server.py` can reuse the existing serialization path."""
try:
track_ids = self._iter_collection_track_ids(max_ids=limit)
if not track_ids:
return []
hydrated: List[Track] = []
for i in range(0, len(track_ids), self._COLLECTION_BATCH_SIZE):
batch = track_ids[i:i + self._COLLECTION_BATCH_SIZE]
try:
hydrated.extend(self._get_tracks_batch(batch))
except Exception as e:
logger.debug(
f"Tidal collection batch hydration failed for IDs {batch}: {e}"
)
logger.info(
f"Retrieved {len(hydrated)}/{len(track_ids)} tracks from Tidal Favorite Tracks"
)
return hydrated
except Exception as e:
logger.error(f"Error fetching Tidal collection tracks: {e}")
return []
# ------------------------------------------------------------------
# Favorited albums + artists — V2 collection endpoints
# ------------------------------------------------------------------
#
# Same problem the tracks side hit on issue #502: the prior
# `/v2/favorites?filter[type]=ALBUMS|ARTISTS` endpoints are
# deprecated (404) and the V1 fallback (`/v1/users/<id>/favorites/
# albums|artists`) returns 403 because modern OAuth tokens with
# `collection.read` scope don't have the legacy `r_usr` scope V1
# requires. Discord-reported symptom: Discover → Your Albums (and
# Your Artists) section shows nothing for Tidal users regardless
# of how many albums/artists they've favorited.
#
# Fix mirrors the tracks path:
# 1) Cursor-walk `/v2/userCollection{Albums|Artists}/me/relationships/items`
# via `_iter_collection_album_ids` / `_iter_collection_artist_ids`
# (lifted into the generic `_iter_collection_resource_ids` helper).
# 2) Batch-hydrate via `/v2/{albums|artists}?filter[id]=...&include=...`
# with single-request fan-out (artists+coverArt for albums,
# profileArt for artists). Parses JSON:API `included[]` for
# artist names + image URLs.
#
# Public surface preserves the existing return shape — list of
# dicts matching what `database.upsert_liked_album` /
# `upsert_liked_artist` consume — so web_server.py callers
# (`/api/discover/your-albums-fetch` and equivalent) stay
# byte-identical.
@rate_limited
def _get_albums_batch(self, album_ids: List[str]) -> List[Dict[str, Any]]:
"""Batch-fetch album metadata + cover art + artist names in
one request via JSON:API extended-include semantics. Returns
list of dicts matching `database.upsert_liked_album` kwargs."""
if not album_ids:
return []
try:
params = {
'countryCode': 'US',
'include': 'artists,coverArt',
'filter[id]': ','.join(album_ids),
}
headers = {'accept': 'application/vnd.api+json'}
resp = self.session.get(
f"{self.base_url}/albums",
params=params, headers=headers, timeout=15,
)
if resp.status_code != 200:
logger.debug(
f"Tidal albums batch returned {resp.status_code}: {resp.text[:200]}"
)
return []
data = resp.json()
artists_by_id, artworks_by_id = self._build_included_maps(data.get('included', []))
results: List[Dict[str, Any]] = []
for item in data.get('data', []):
if item.get('type') != 'albums':
continue
attrs = item.get('attributes', {})
rels = item.get('relationships', {})
results.append({
'tidal_id': str(item.get('id', '')),
'album_name': attrs.get('title', '') or '',
'artist_name': self._first_artist_name(rels, artists_by_id),
'image_url': self._first_artwork_url(rels.get('coverArt', {}), artworks_by_id),
'release_date': attrs.get('releaseDate', '') or '',
'total_tracks': int(attrs.get('numberOfItems') or 0),
})
return results
except Exception as e:
logger.debug(f"Tidal _get_albums_batch error: {e}")
return []
@rate_limited
def _get_artists_batch(self, artist_ids: List[str]) -> List[Dict[str, Any]]:
"""Batch-fetch artist metadata + profile image. Returns list
of dicts matching the prior `get_favorite_artists` shape
(`tidal_id`, `name`, `image_url`)."""
if not artist_ids:
return []
try:
params = {
'countryCode': 'US',
'include': 'profileArt',
'filter[id]': ','.join(artist_ids),
}
headers = {'accept': 'application/vnd.api+json'}
resp = self.session.get(
f"{self.base_url}/artists",
params=params, headers=headers, timeout=15,
)
if resp.status_code != 200:
logger.debug(
f"Tidal artists batch returned {resp.status_code}: {resp.text[:200]}"
)
return []
data = resp.json()
_, artworks_by_id = self._build_included_maps(data.get('included', []))
results: List[Dict[str, Any]] = []
for item in data.get('data', []):
if item.get('type') != 'artists':
continue
attrs = item.get('attributes', {})
rels = item.get('relationships', {})
results.append({
'tidal_id': str(item.get('id', '')),
'name': attrs.get('name', '') or '',
'image_url': self._first_artwork_url(rels.get('profileArt', {}), artworks_by_id),
})
return results
except Exception as e:
logger.debug(f"Tidal _get_artists_batch error: {e}")
return []
@staticmethod
def _build_included_maps(included: List[Dict[str, Any]]):
"""Index a JSON:API `included[]` array by resource type so the
per-resource lookup in batch-hydrate is O(1) per relationship
ref rather than O(n)."""
artists_by_id: Dict[str, Dict[str, Any]] = {}
artworks_by_id: Dict[str, Dict[str, Any]] = {}
for inc in included:
inc_id = str(inc.get('id', ''))
if not inc_id:
continue
inc_type = inc.get('type')
if inc_type == 'artists':
artists_by_id[inc_id] = inc
elif inc_type == 'artworks':
artworks_by_id[inc_id] = inc
return artists_by_id, artworks_by_id
@staticmethod
def _first_artist_name(relationships: Dict[str, Any],
artists_by_id: Dict[str, Dict[str, Any]]) -> str:
"""Resolve the primary artist name from a relationships block
+ included-artists map. Returns '' if not resolvable so the
upsert path doesn't trip on None."""
artist_refs = relationships.get('artists', {}).get('data', [])
if not artist_refs:
return ''
first_id = str(artist_refs[0].get('id', ''))
artist_obj = artists_by_id.get(first_id, {})
return artist_obj.get('attributes', {}).get('name', '') or ''
@staticmethod
def _first_artwork_url(artwork_relationship: Dict[str, Any],
artworks_by_id: Dict[str, Dict[str, Any]]) -> Optional[str]:
"""Resolve the largest cover/profile image URL from an artwork
relationship + included-artworks map. Tidal returns files
largest-first so picking files[0] gets the highest-resolution
variant (typically 1280×1280)."""
refs = artwork_relationship.get('data', [])
if not refs:
return None
first_id = str(refs[0].get('id', ''))
artwork = artworks_by_id.get(first_id, {})
files = artwork.get('attributes', {}).get('files', [])
if not files:
return None
return files[0].get('href')
def get_favorite_albums(self, limit: int = 200) -> List[Dict[str, Any]]:
"""Fetch user's favorited albums via the V2 user-collection
endpoint. Replaces the prior `/v2/favorites` + V1-fallback
path which is now dead (V2 endpoint deprecated, V1 returns
403 for modern OAuth tokens lacking `r_usr` scope).
Returns list of dicts matching `database.upsert_liked_album`
kwargs — the discover.py 'Your Albums' aggregator iterates
these and writes them to the liked_albums table."""
try:
album_ids = self._iter_collection_album_ids(max_ids=limit)
if not album_ids:
return []
results: List[Dict[str, Any]] = []
for i in range(0, len(album_ids), self._COLLECTION_BATCH_SIZE):
batch = album_ids[i:i + self._COLLECTION_BATCH_SIZE]
results.extend(self._get_albums_batch(batch))
logger.info(
f"Retrieved {len(results)}/{len(album_ids)} favorite albums from Tidal"
)
return results
except Exception as e:
logger.error(f"Error fetching Tidal favorite albums: {e}")
return []
def get_favorite_artists(self, limit: int = 200) -> List[Dict[str, Any]]:
"""Fetch user's favorited artists via the V2 user-collection
endpoint. Replaces the prior `/v2/favorites` + V1-fallback
path (dead for the same reason as `get_favorite_albums`).
Returns list of dicts matching the prior shape (`tidal_id`,
`name`, `image_url`) so web_server.py's `/api/discover/
your-artists-fetch` aggregator path stays byte-identical."""
try:
artist_ids = self._iter_collection_artist_ids(max_ids=limit)
if not artist_ids:
return []
results: List[Dict[str, Any]] = []
for i in range(0, len(artist_ids), self._COLLECTION_BATCH_SIZE):
batch = artist_ids[i:i + self._COLLECTION_BATCH_SIZE]
results.extend(self._get_artists_batch(batch))
logger.info(
f"Retrieved {len(results)}/{len(artist_ids)} favorite artists from Tidal"
)
return results
except Exception as e:
logger.error(f"Error fetching Tidal favorite artists: {e}")
return []
# 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