Refactor playlist fetching to use Tidal JSON:API

Rewrites playlist and track fetching to use Tidal's JSON:API endpoints with cursor-based pagination and batch track hydration. Adds robust rate limiting with retry logic, and introduces ISO-8601 duration parsing. This improves reliability, performance, and compatibility with Tidal's latest API structure.
This commit is contained in:
Broque Thomas 2026-01-15 06:44:35 -08:00
parent f4bdb76c72
commit 665281f184

View file

@ -1,5 +1,6 @@
import requests import requests
import time import time
import re
import threading import threading
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from functools import wraps from functools import wraps
@ -23,33 +24,49 @@ _api_call_lock = threading.Lock()
MIN_API_INTERVAL = 0.2 # 200ms between API calls MIN_API_INTERVAL = 0.2 # 200ms between API calls
def rate_limited(func): def rate_limited(func):
"""Decorator to enforce rate limiting on Tidal API calls""" """Decorator to enforce rate limiting on Tidal API calls with retry logic"""
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
global _last_api_call_time max_retries = 3
last_exception = None
with _api_call_lock:
current_time = time.time() for attempt in range(max_retries):
time_since_last_call = current_time - _last_api_call_time global _last_api_call_time
if time_since_last_call < MIN_API_INTERVAL: with _api_call_lock:
sleep_time = MIN_API_INTERVAL - time_since_last_call current_time = time.time()
time.sleep(sleep_time) time_since_last_call = current_time - _last_api_call_time
_last_api_call_time = time.time() if time_since_last_call < MIN_API_INTERVAL:
sleep_time = MIN_API_INTERVAL - time_since_last_call
try: time.sleep(sleep_time)
result = func(*args, **kwargs)
return result _last_api_call_time = time.time()
except Exception as e:
# Implement exponential backoff for API errors try:
if "rate limit" in str(e).lower() or "429" in str(e): result = func(*args, **kwargs)
logger.warning(f"Rate limit hit, implementing backoff: {e}") return result
time.sleep(3.0) # Wait 3 seconds before retrying except Exception as e:
elif "503" in str(e) or "502" in str(e): last_exception = e
logger.warning(f"Tidal service error, backing off: {e}") error_str = str(e)
time.sleep(2.0) # Wait 2 seconds for service errors
raise # Only retry on specific errors
if "rate limit" in error_str.lower() or "429" in error_str:
logger.warning(f"Rate limit hit on attempt {attempt + 1}/{max_retries}, backing off: {e}")
if attempt < max_retries - 1:
time.sleep(3.0)
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 return wrapper
@dataclass @dataclass
@ -550,7 +567,7 @@ class TidalClient:
endpoint = f"{self.base_url}/playlists" endpoint = f"{self.base_url}/playlists"
params = { params = {
'countryCode': 'US', 'countryCode': 'US',
'filter[r.owners.id]': user_id 'filter[owners.id]': user_id
} }
headers = self.session.headers.copy() headers = self.session.headers.copy()
@ -687,97 +704,217 @@ class TidalClient:
@rate_limited @rate_limited
def get_playlist(self, playlist_id: str) -> Optional[Playlist]: def get_playlist(self, playlist_id: str) -> Optional[Playlist]:
"""Get playlist details including tracks""" """Get playlist details including tracks using JSON:API format"""
try: try:
if not self._ensure_valid_token(): if not self._ensure_valid_token():
logger.error("Not authenticated with Tidal") logger.error("Not authenticated with Tidal")
return None return None
# Get playlist metadata # Get playlist metadata with JSON:API format
headers = {'accept': 'application/vnd.api+json'}
response = self.session.get( response = self.session.get(
f"{self.base_url}/playlists/{playlist_id}", f"{self.base_url}/playlists/{playlist_id}",
params={'countryCode': 'US'}, params={'countryCode': 'US'},
headers=headers,
timeout=10 timeout=10
) )
response.raise_for_status()
if response.status_code != 200: if response.status_code != 200:
logger.error(f"Failed to get Tidal playlist {playlist_id}: {response.status_code} - {response.text}") logger.error(f"Failed to get Tidal playlist {playlist_id}: {response.status_code} - {response.text}")
return None return None
playlist_data = response.json()
# Get playlist tracks with pagination to handle large playlists # 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 = [] tracks = []
offset = 0 cursor = None
limit = 100
total_fetched = 0 total_fetched = 0
while True: while True:
logger.info(f"Fetching tracks for playlist {playlist_id}: offset={offset}, limit={limit}") # Fetch a page of track IDs
tracks_page = self._get_playlist_tracks_page(playlist_id, cursor)
tracks_response = self.session.get( if not tracks_page or not tracks_page.get("data"):
f"{self.base_url}/playlists/{playlist_id}/items", logger.info(f"No more tracks found, stopping pagination")
params={'countryCode': 'US', 'limit': limit, 'offset': offset},
timeout=10
)
if tracks_response.status_code != 200:
logger.error(f"Failed to get Tidal playlist tracks at offset {offset}: {tracks_response.status_code} - {tracks_response.text}")
break break
tracks_data = tracks_response.json() # 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 'items' not in tracks_data: if track_ids:
logger.warning(f"No items found in playlist {playlist_id} response at offset {offset}") # Batch fetch full track details with artists and albums
batch_tracks = self._get_tracks_batch(track_ids)
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 meta (not nested in links)
cursor = tracks_page.get("meta", {}).get("nextCursor")
# If no next cursor exists, we're done
if not cursor:
logger.info("No next cursor found, pagination complete")
break break
items = tracks_data['items']
if len(items) == 0:
logger.info(f"No more tracks found at offset {offset}, stopping pagination")
break
# Process this batch of tracks
batch_count = 0
for item in 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)
batch_count += 1
total_fetched += batch_count
logger.info(f"Fetched {batch_count} tracks in this batch, {total_fetched} total so far")
# If we got fewer items than the limit, we've reached the end
if len(items) < limit:
logger.info(f"Received {len(items)} items (less than limit {limit}), pagination complete")
break
# Move to next page
offset += limit
playlist = Playlist( playlist = Playlist(
id=playlist_data.get('id', playlist_id), id=playlist_data.get('id', playlist_id),
name=playlist_data.get('title', 'Unknown Playlist'), name=playlist_attrs.get('name', 'Unknown Playlist'),
description=playlist_data.get('description', ''), description=playlist_attrs.get('description', ''),
tracks=tracks, tracks=tracks,
external_urls={'tidal': f"https://tidal.com/browse/playlist/{playlist_id}"}, external_urls={'tidal': f"https://listen.tidal.com/playlist/{playlist_id}"},
public=not playlist_data.get('publicPlaylist', True) # Tidal uses 'publicPlaylist' field public=playlist_attrs.get('accessType', '') == "PUBLIC"
) )
logger.info(f"Retrieved Tidal playlist '{playlist.name}' with {len(tracks)} tracks") logger.info(f"Retrieved Tidal playlist '{playlist.name}' with {len(tracks)} tracks")
return playlist return playlist
except Exception as e: except Exception as e:
logger.error(f"Error getting Tidal playlist {playlist_id}: {e}") logger.error(f"Error getting Tidal playlist {playlist_id}: {e}")
return None 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 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', ''))
hydrated_tracks.append(Track(
id=str(track_id),
name=attrs.get('title', 'Unknown Track'),
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 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]: def _parse_track_data(self, item: Dict[str, Any]) -> Optional[Track]:
"""Parse Tidal track data into Track object""" """Parse Tidal track data into Track object"""
try: try: