updated tidal client playlist parsing

This commit is contained in:
nathan 2026-01-14 19:38:04 +00:00
parent f4bdb76c72
commit 76760bdaaf

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
@ -163,7 +164,7 @@ class TidalClient:
logger.info("Saved Tidal tokens") logger.info("Saved Tidal tokens")
except Exception as e: except Exception as e:
logger.error(f"Error saving Tidal tokens: {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]: 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.""" """Parse a track from a JSON:API 'included' object with artist details."""
try: try:
@ -550,7 +551,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()
@ -688,6 +689,7 @@ 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"""
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")
@ -708,67 +710,65 @@ class TidalClient:
# Get playlist tracks with pagination to handle large playlists # Get playlist tracks with pagination to handle large playlists
tracks = [] tracks = []
offset = 0 cursor = ""
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}") track_ids = []
logger.info(f"Fetching tracks for playlist {playlist_id}: cursor={cursor}")
params = {"countryCode": "US"}
if cursor:
params["page[cursor]"] = cursor
tracks_response = self.session.get( tracks_response = self.session.get(
f"{self.base_url}/playlists/{playlist_id}/items", f"{self.base_url}/playlists/{playlist_id}/relationships/items",
params={'countryCode': 'US', 'limit': limit, 'offset': offset}, params=params,
timeout=10 timeout=10
) )
if tracks_response.status_code != 200: 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}") logger.error(f"Failed to get Tidal playlist tracks at cursor {cursor}: {tracks_response.status_code} - {tracks_response.text}")
break break
tracks_data = tracks_response.json() tracks_data = tracks_response.json()
if 'items' not in tracks_data: if not tracks_data.get("data"):
logger.warning(f"No items found in playlist {playlist_id} response at offset {offset}") logger.warning(f"No items found in playlist {playlist_id} response at cursor {cursor}")
break
items = tracks_data['items']
if len(items) == 0:
logger.info(f"No more tracks found at offset {offset}, stopping pagination")
break break
# Process this batch of tracks # Process this batch of tracks
batch_count = 0 batch_count = 0
for item in items: for item in tracks_data.get("data",[]):
# Handle different item structures # we can do a batch call with the tracks to get the artist data
track_data = item if item.get("type"):
if 'item' in item and item.get('type') == 'track': track_ids.append(item.get("id"))
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 batch_count += 1
total_fetched += batch_count total_fetched += batch_count
logger.info(f"Fetched {batch_count} tracks in this batch, {total_fetched} total so far") 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 # now we have a page of tracks we can hydrate some of the data
if len(items) < limit:
logger.info(f"Received {len(items)} items (less than limit {limit}), pagination complete") tracks.extend(self._get_track_playlist(track_ids))
break
# Move to next page # Move to next page
offset += limit cursor = tracks_data.get("links", {}).get("meta", {}).get("nextCursor")
# Tidal uses cursor based pagination so if no next cursor exists we can finish
if not cursor:
break
# now we have a list of track ids but not hydrated track data
playlist_attrs = playlist_data.get("attributes", {})
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://tidal.com/browse/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")
@ -778,6 +778,89 @@ class TidalClient:
logger.error(f"Error getting Tidal playlist {playlist_id}: {e}") logger.error(f"Error getting Tidal playlist {playlist_id}: {e}")
return None return None
def parse_duration(self, duration: str) -> int:
"""Convert ISO-8601 duration string (like 'PT3M36S') to milliseconds.
Only supports minutes and seconds.
"""
if not duration.startswith("PT"):
return 0
minutes = 0
seconds = 0
# Extract minutes and seconds using regex
m = re.search(r"(\d+)M", duration)
s = re.search(r"(\d+)S", duration)
if m:
minutes = int(m.group(1))
if s:
seconds = int(s.group(1))
return (minutes * 60 + seconds) * 1000
def _get_playlist_track_data(self, track_ids: list[str]) -> list[Track]:
try:
params = {"countryCode": "US", "include":"artists,albums", "filter[id]": ",".join(track_ids)}
resp = self.session.get(
f"{self.base_url}/tracks",
params=params,
timeout=10
)
if resp.status_code != 200:
logger.error(f"Failed to get Tidal playlist tracks data: {resp.status_code} - {resp.text}")
tracks_data = resp.json()
albumCache = Dict[str,str] = {}
artistCache = Dict[str,str] = {}
# first scan through the included albums and artists so we can link ids to names
for item in tracks_data.get("included"):
item_id = item.get("id")
if item.get("type") == "albums":
albumCache.setdefault(item_id, item.get("attributes", {}).get("title", "Unknown Album"))
if item.get("type") == "artists":
artistCache.setdefault(item_id, item.get("attributes", {}).get("name", "Unknown Artist"))
# now go through the tracks and hydrate with the artist and album data
hydratedTracks: list[Track] = []
for trackData in tracks_data.get("data"):
attrs = trackData.get("attributes", {})
track_id = trackData.get("id")
relateds = trackData.get("relationships")
album_data = relateds.get("albums", {}).get("data", [])
album_id = album_data[0].get("id") if album_data else None
album = albumCache.get(album_id, "Unknown Album")
artists = [
artistCache.get(a.get("id"), "Unknown Artist")
for a in relateds.get("artists", {}).get("data", [])
if a.get("id")
]
hydratedTracks.append(Track(
id=str(track_id),
name=attrs.get('title', 'Unknown Track'),
artists=artists,
album=album,
duration_ms=self.parse_duration(attrs.get('duration')),
external_urls={'tidal': f"https://tidal.com/browse/track/{track_id}"},
explicit=attrs.get('explicit', False)
))
except Exception as e:
logger.error(f"Error getting playlist tracks: {e}")
return []
return hydratedTracks
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: