Fix Tidal V2 search endpoint, duration parsing, and library badge display
This commit is contained in:
parent
cc35864e7d
commit
ecfa30c918
5 changed files with 93 additions and 19 deletions
|
|
@ -340,6 +340,11 @@ class QobuzWorker:
|
||||||
self._process_track(item_id, item_name, item.get('artist', ''), item)
|
self._process_track(item_id, item_name, item.get('artist', ''), item)
|
||||||
|
|
||||||
except Exception as e:
|
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}")
|
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
|
||||||
self.stats['errors'] += 1
|
self.stats['errors'] += 1
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -696,23 +696,40 @@ class TidalClient:
|
||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if response.status_code == 429:
|
||||||
|
raise Exception("Rate limited (429) on search_tracks")
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
tracks = []
|
tracks = []
|
||||||
|
|
||||||
if 'tracks' in data and 'items' in data['tracks']:
|
# Handle V2 JSON:API response formats
|
||||||
for item in data['tracks']['items']:
|
items = []
|
||||||
track = self._parse_track_data(item)
|
if 'tracks' in data and isinstance(data['tracks'], list):
|
||||||
if track:
|
items = data['tracks']
|
||||||
tracks.append(track)
|
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}'")
|
logger.info(f"Found {len(tracks)} Tidal tracks for query: '{query}'")
|
||||||
return tracks
|
return tracks
|
||||||
else:
|
else:
|
||||||
logger.error(f"Tidal search failed: {response.status_code} - {response.text}")
|
logger.error(f"Tidal search failed: {response.status_code} - {response.text}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if "429" in str(e):
|
||||||
|
raise # Let rate_limited decorator handle retry
|
||||||
logger.error(f"Error searching Tidal tracks: {e}")
|
logger.error(f"Error searching Tidal tracks: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -807,6 +824,13 @@ class TidalClient:
|
||||||
if 'attributes' in item and 'id' in item:
|
if 'attributes' in item and 'id' in item:
|
||||||
flat = dict(item['attributes'])
|
flat = dict(item['attributes'])
|
||||||
flat['id'] = item['id']
|
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 flat
|
||||||
return item
|
return item
|
||||||
else:
|
else:
|
||||||
|
|
@ -857,6 +881,13 @@ class TidalClient:
|
||||||
if 'attributes' in item and 'id' in item:
|
if 'attributes' in item and 'id' in item:
|
||||||
flat = dict(item['attributes'])
|
flat = dict(item['attributes'])
|
||||||
flat['id'] = item['id']
|
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 flat
|
||||||
return item
|
return item
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,28 @@ from core.tidal_client import TidalClient
|
||||||
logger = get_logger("tidal_worker")
|
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:
|
class TidalWorker:
|
||||||
"""Background worker for enriching library artists, albums, and tracks with Tidal metadata"""
|
"""Background worker for enriching library artists, albums, and tracks with Tidal metadata"""
|
||||||
|
|
||||||
|
|
@ -332,8 +354,9 @@ class TidalWorker:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_str = str(e).lower()
|
error_str = str(e).lower()
|
||||||
if '429' in error_str or 'rate limit' in error_str:
|
if '429' in error_str or 'rate limit' in error_str:
|
||||||
# Rate limit — don't mark as error, leave for retry on next loop
|
# Rate limit — don't mark as error, back off then retry
|
||||||
logger.warning(f"Rate limited while processing {item['type']} #{item['id']}, will retry")
|
logger.warning(f"Rate limited while processing {item['type']} #{item['id']}, backing off 30s")
|
||||||
|
time.sleep(30)
|
||||||
return
|
return
|
||||||
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
|
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
|
||||||
self.stats['errors'] += 1
|
self.stats['errors'] += 1
|
||||||
|
|
@ -590,10 +613,9 @@ class TidalWorker:
|
||||||
WHERE id = ? AND track_count IS NULL
|
WHERE id = ? AND track_count IS NULL
|
||||||
""", (num_tracks, album_id))
|
""", (num_tracks, album_id))
|
||||||
|
|
||||||
# Backfill duration (Tidal returns seconds, DB stores milliseconds)
|
# Backfill duration (Tidal returns seconds or ISO-8601, DB stores milliseconds)
|
||||||
duration = data.get('duration')
|
duration_ms = _parse_duration_to_ms(data.get('duration'))
|
||||||
if duration and isinstance(duration, (int, float)) and duration > 0:
|
if duration_ms:
|
||||||
duration_ms = int(duration * 1000)
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
UPDATE albums SET duration = ?
|
UPDATE albums SET duration = ?
|
||||||
WHERE id = ? AND duration IS NULL
|
WHERE id = ? AND duration IS NULL
|
||||||
|
|
@ -677,9 +699,8 @@ class TidalWorker:
|
||||||
WHERE id = ? AND (isrc IS NULL OR isrc = '')
|
WHERE id = ? AND (isrc IS NULL OR isrc = '')
|
||||||
""", (isrc, track_id))
|
""", (isrc, track_id))
|
||||||
|
|
||||||
duration = data.get('duration')
|
duration_ms = _parse_duration_to_ms(data.get('duration'))
|
||||||
if duration and isinstance(duration, (int, float)) and duration > 0:
|
if duration_ms:
|
||||||
duration_ms = int(duration * 1000)
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
UPDATE tracks SET duration = ?
|
UPDATE tracks SET duration = ?
|
||||||
WHERE id = ? AND duration IS NULL
|
WHERE id = ? AND duration IS NULL
|
||||||
|
|
|
||||||
|
|
@ -6054,6 +6054,8 @@ class MusicDatabase:
|
||||||
a.audiodb_id,
|
a.audiodb_id,
|
||||||
a.lastfm_url,
|
a.lastfm_url,
|
||||||
a.genius_url,
|
a.genius_url,
|
||||||
|
a.tidal_id,
|
||||||
|
a.qobuz_id,
|
||||||
COUNT(DISTINCT al.id) as album_count,
|
COUNT(DISTINCT al.id) as album_count,
|
||||||
COUNT(DISTINCT t.id) as track_count
|
COUNT(DISTINCT t.id) as track_count
|
||||||
FROM artists a
|
FROM artists a
|
||||||
|
|
@ -6064,7 +6066,7 @@ class MusicDatabase:
|
||||||
WHERE {where_clause}
|
WHERE {where_clause}
|
||||||
AND a.id = (SELECT MIN(a2.id) FROM artists a2
|
AND a.id = (SELECT MIN(a2.id) FROM artists a2
|
||||||
WHERE a2.name = a.name AND a2.server_source = a.server_source)
|
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
|
ORDER BY a.name COLLATE NOCASE
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
"""
|
"""
|
||||||
|
|
@ -6114,6 +6116,8 @@ class MusicDatabase:
|
||||||
'audiodb_id': row['audiodb_id'],
|
'audiodb_id': row['audiodb_id'],
|
||||||
'lastfm_url': row['lastfm_url'],
|
'lastfm_url': row['lastfm_url'],
|
||||||
'genius_url': row['genius_url'],
|
'genius_url': row['genius_url'],
|
||||||
|
'tidal_id': row['tidal_id'],
|
||||||
|
'qobuz_id': row['qobuz_id'],
|
||||||
'album_count': row['album_count'] or 0,
|
'album_count': row['album_count'] or 0,
|
||||||
'track_count': row['track_count'] or 0,
|
'track_count': row['track_count'] or 0,
|
||||||
'is_watched': bool(is_watched)
|
'is_watched': bool(is_watched)
|
||||||
|
|
@ -6171,7 +6175,8 @@ class MusicDatabase:
|
||||||
SELECT
|
SELECT
|
||||||
id, name, thumb_url, genres, server_source,
|
id, name, thumb_url, genres, server_source,
|
||||||
musicbrainz_id, deezer_id, audiodb_id,
|
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
|
FROM artists
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""", (artist_id,))
|
""", (artist_id,))
|
||||||
|
|
@ -6324,6 +6329,8 @@ class MusicDatabase:
|
||||||
'itunes_artist_id': artist_row['itunes_artist_id'],
|
'itunes_artist_id': artist_row['itunes_artist_id'],
|
||||||
'lastfm_url': artist_row['lastfm_url'],
|
'lastfm_url': artist_row['lastfm_url'],
|
||||||
'genius_url': artist_row['genius_url'],
|
'genius_url': artist_row['genius_url'],
|
||||||
|
'tidal_id': artist_row['tidal_id'],
|
||||||
|
'qobuz_id': artist_row['qobuz_id'],
|
||||||
'album_count': album_count,
|
'album_count': album_count,
|
||||||
'track_count': track_count
|
'track_count': track_count
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -35209,6 +35209,16 @@ function getServiceUrl(service, entityType, id) {
|
||||||
artist: id, // genius_url is already a full URL
|
artist: id, // genius_url is already a full URL
|
||||||
track: id, // genius_url on tracks 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;
|
return urls[service] && urls[service][entityType] || null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue