From ecfa30c918d0285db66b4ae9c0c925eeafed85fb Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:08:25 -0700 Subject: [PATCH] Fix Tidal V2 search endpoint, duration parsing, and library badge display --- core/qobuz_worker.py | 5 ++++ core/tidal_client.py | 47 +++++++++++++++++++++++++++++++------- core/tidal_worker.py | 39 +++++++++++++++++++++++-------- database/music_database.py | 11 +++++++-- webui/static/script.js | 10 ++++++++ 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/core/qobuz_worker.py b/core/qobuz_worker.py index b5b351cd..06067fe9 100644 --- a/core/qobuz_worker.py +++ b/core/qobuz_worker.py @@ -340,6 +340,11 @@ class QobuzWorker: self._process_track(item_id, item_name, item.get('artist', ''), item) except Exception as e: + error_str = str(e).lower() + if '429' in error_str or 'rate limit' in error_str: + logger.warning(f"Rate limited while processing {item['type']} #{item['id']}, backing off 30s") + time.sleep(30) + return logger.error(f"Error processing {item['type']} #{item['id']}: {e}") self.stats['errors'] += 1 try: diff --git a/core/tidal_client.py b/core/tidal_client.py index 73a88b1b..7e40a9fa 100644 --- a/core/tidal_client.py +++ b/core/tidal_client.py @@ -696,23 +696,40 @@ class TidalClient: timeout=10 ) + if response.status_code == 429: + raise Exception("Rate limited (429) on search_tracks") 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) - + + # 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 [] @@ -807,6 +824,13 @@ class TidalClient: 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 return flat return item else: @@ -857,6 +881,13 @@ class TidalClient: 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 return flat return item else: diff --git a/core/tidal_worker.py b/core/tidal_worker.py index 9dcfa8a9..6e39b336 100644 --- a/core/tidal_worker.py +++ b/core/tidal_worker.py @@ -11,6 +11,28 @@ from core.tidal_client import TidalClient logger = get_logger("tidal_worker") +def _parse_duration_to_ms(duration) -> Optional[int]: + """Convert duration to milliseconds. Handles integer seconds and ISO-8601 strings (PT3M36S).""" + if not duration: + return None + if isinstance(duration, (int, float)) and duration > 0: + return int(duration * 1000) + if isinstance(duration, str) and duration.startswith('PT'): + total_seconds = 0 + 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)) + if total_seconds > 0: + return total_seconds * 1000 + return None + + class TidalWorker: """Background worker for enriching library artists, albums, and tracks with Tidal metadata""" @@ -332,8 +354,9 @@ class TidalWorker: except Exception as e: error_str = str(e).lower() if '429' in error_str or 'rate limit' in error_str: - # Rate limit — don't mark as error, leave for retry on next loop - logger.warning(f"Rate limited while processing {item['type']} #{item['id']}, will retry") + # Rate limit — don't mark as error, back off then retry + logger.warning(f"Rate limited while processing {item['type']} #{item['id']}, backing off 30s") + time.sleep(30) return logger.error(f"Error processing {item['type']} #{item['id']}: {e}") self.stats['errors'] += 1 @@ -590,10 +613,9 @@ class TidalWorker: WHERE id = ? AND track_count IS NULL """, (num_tracks, album_id)) - # Backfill duration (Tidal returns seconds, DB stores milliseconds) - duration = data.get('duration') - if duration and isinstance(duration, (int, float)) and duration > 0: - duration_ms = int(duration * 1000) + # Backfill duration (Tidal returns seconds or ISO-8601, DB stores milliseconds) + duration_ms = _parse_duration_to_ms(data.get('duration')) + if duration_ms: cursor.execute(""" UPDATE albums SET duration = ? WHERE id = ? AND duration IS NULL @@ -677,9 +699,8 @@ class TidalWorker: WHERE id = ? AND (isrc IS NULL OR isrc = '') """, (isrc, track_id)) - duration = data.get('duration') - if duration and isinstance(duration, (int, float)) and duration > 0: - duration_ms = int(duration * 1000) + duration_ms = _parse_duration_to_ms(data.get('duration')) + if duration_ms: cursor.execute(""" UPDATE tracks SET duration = ? WHERE id = ? AND duration IS NULL diff --git a/database/music_database.py b/database/music_database.py index fa5dbe61..8011f40a 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -6054,6 +6054,8 @@ class MusicDatabase: a.audiodb_id, a.lastfm_url, a.genius_url, + a.tidal_id, + a.qobuz_id, COUNT(DISTINCT al.id) as album_count, COUNT(DISTINCT t.id) as track_count FROM artists a @@ -6064,7 +6066,7 @@ class MusicDatabase: WHERE {where_clause} AND a.id = (SELECT MIN(a2.id) FROM artists a2 WHERE a2.name = a.name AND a2.server_source = a.server_source) - GROUP BY a.id, a.name, a.thumb_url, a.genres, a.musicbrainz_id, a.spotify_artist_id, a.itunes_artist_id, a.deezer_id, a.audiodb_id, a.lastfm_url, a.genius_url + GROUP BY a.id, a.name, a.thumb_url, a.genres, a.musicbrainz_id, a.spotify_artist_id, a.itunes_artist_id, a.deezer_id, a.audiodb_id, a.lastfm_url, a.genius_url, a.tidal_id, a.qobuz_id ORDER BY a.name COLLATE NOCASE LIMIT ? OFFSET ? """ @@ -6114,6 +6116,8 @@ class MusicDatabase: 'audiodb_id': row['audiodb_id'], 'lastfm_url': row['lastfm_url'], 'genius_url': row['genius_url'], + 'tidal_id': row['tidal_id'], + 'qobuz_id': row['qobuz_id'], 'album_count': row['album_count'] or 0, 'track_count': row['track_count'] or 0, 'is_watched': bool(is_watched) @@ -6171,7 +6175,8 @@ class MusicDatabase: SELECT id, name, thumb_url, genres, server_source, musicbrainz_id, deezer_id, audiodb_id, - spotify_artist_id, itunes_artist_id, lastfm_url, genius_url + spotify_artist_id, itunes_artist_id, lastfm_url, genius_url, + tidal_id, qobuz_id FROM artists WHERE id = ? """, (artist_id,)) @@ -6324,6 +6329,8 @@ class MusicDatabase: 'itunes_artist_id': artist_row['itunes_artist_id'], 'lastfm_url': artist_row['lastfm_url'], 'genius_url': artist_row['genius_url'], + 'tidal_id': artist_row['tidal_id'], + 'qobuz_id': artist_row['qobuz_id'], 'album_count': album_count, 'track_count': track_count }, diff --git a/webui/static/script.js b/webui/static/script.js index 7c2e0278..fa0e9dc7 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -35209,6 +35209,16 @@ function getServiceUrl(service, entityType, id) { artist: id, // genius_url is already a full URL track: id, // genius_url on tracks is already a full URL }, + tidal: { + artist: `https://tidal.com/browse/artist/${id}`, + album: `https://tidal.com/browse/album/${id}`, + track: `https://tidal.com/browse/track/${id}`, + }, + qobuz: { + artist: `https://www.qobuz.com/artist/${id}`, + album: `https://www.qobuz.com/album/${id}`, + track: `https://www.qobuz.com/track/${id}`, + }, }; return urls[service] && urls[service][entityType] || null; }