Skip to content

Commit 480526d

Browse files
Mypy and track fallback fixes for Tidal provider (#1926)
* refactor: cleanup based on mypy * feat: add fallback track lookup by isrc * feat: add cache to isrc lookup
1 parent 44b3e8f commit 480526d

File tree

2 files changed

+132
-28
lines changed

2 files changed

+132
-28
lines changed

music_assistant/providers/tidal/__init__.py

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@
7676
get_similar_tracks,
7777
get_stream,
7878
get_track,
79+
get_track_lyrics,
80+
get_tracks_by_isrc,
7981
library_items_add_remove,
8082
remove_playlist_tracks,
8183
search,
@@ -545,20 +547,24 @@ async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool
545547
async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
546548
"""Add track(s) to playlist."""
547549
tidal_session = await self._get_tidal_session()
548-
return await add_playlist_tracks(tidal_session, prov_playlist_id, prov_track_ids)
550+
await add_playlist_tracks(tidal_session, prov_playlist_id, prov_track_ids)
549551

550552
async def remove_playlist_tracks(
551553
self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
552554
) -> None:
553555
"""Remove track(s) from playlist."""
554-
prov_track_ids = []
555556
tidal_session = await self._get_tidal_session()
557+
prov_track_ids: list[str] = []
558+
# Get tracks by position
556559
for pos in positions_to_remove:
557-
for tidal_track in await get_playlist_tracks(
560+
tracks = await get_playlist_tracks(
558561
tidal_session, prov_playlist_id, limit=1, offset=pos - 1
559-
):
560-
prov_track_ids.append(tidal_track.id)
561-
return await remove_playlist_tracks(tidal_session, prov_playlist_id, prov_track_ids)
562+
)
563+
if tracks and len(tracks) > 0:
564+
prov_track_ids.append(str(tracks[0].id))
565+
566+
if prov_track_ids:
567+
await remove_playlist_tracks(tidal_session, prov_playlist_id, prov_track_ids)
562568

563569
async def create_playlist(self, name: str) -> Playlist:
564570
"""Create a new playlist on provider with given name."""
@@ -577,17 +583,30 @@ async def get_stream_details(
577583
"""Return the content details for the given track when it will be streamed."""
578584
tidal_session = await self._get_tidal_session()
579585
# make sure a valid track is requested.
580-
if not (track := await get_track(tidal_session, item_id)):
581-
msg = f"track {item_id} not found"
582-
raise MediaNotFoundError(msg)
586+
# Try direct track lookup first with exception handling
587+
try:
588+
track = await get_track(tidal_session, item_id)
589+
except MediaNotFoundError:
590+
# Fallback to ISRC lookup
591+
self.logger.info(
592+
"""Track %s not found, attempting fallback by ISRC.
593+
It's likely that this track has a new ID upstream in Tidal's WebApp.""",
594+
item_id,
595+
)
596+
track = await self._get_track_by_isrc(item_id, tidal_session)
597+
if not track:
598+
raise MediaNotFoundError(f"Track {item_id} not found")
599+
583600
stream: TidalStream = await get_stream(track)
584601
manifest = stream.get_stream_manifest()
585-
if stream.is_mpd:
602+
603+
url = (
586604
# for mpeg-dash streams we just pass the complete base64 manifest
587-
url = f"data:application/dash+xml;base64,{manifest.manifest}"
588-
else:
605+
f"data:application/dash+xml;base64,{manifest.manifest}"
606+
if stream.is_mpd
589607
# as far as I can oversee a BTS stream is just a single URL
590-
url = manifest.urls[0]
608+
else manifest.urls[0]
609+
)
591610

592611
return StreamDetails(
593612
item_id=track.id,
@@ -632,8 +651,9 @@ async def get_track(self, prov_track_id: str) -> Track:
632651
track = self._parse_track(track_obj)
633652
# get some extra details for the full track info
634653
with suppress(tidal_exceptions.MetadataNotAvailable, AttributeError):
635-
lyrics: TidalLyrics = await asyncio.to_thread(track_obj.lyrics)
636-
track.metadata.lyrics = lyrics.text
654+
lyrics: TidalLyrics = await get_track_lyrics(tidal_session, prov_track_id)
655+
if lyrics and hasattr(lyrics, "text"):
656+
track.metadata.lyrics = lyrics.text
637657
return track
638658
except tidal_exceptions.ObjectNotFound as err:
639659
raise MediaNotFoundError from err
@@ -713,6 +733,58 @@ def inner() -> TidalSession:
713733

714734
return await asyncio.to_thread(inner)
715735

736+
async def _get_track_by_isrc(
737+
self, item_id: str, tidal_session: TidalSession
738+
) -> TidalTrack | None:
739+
"""Get track by ISRC from library item, with caching."""
740+
# Try to get from cache first
741+
cache_key = f"isrc_map_{item_id}"
742+
cached_track_id = await self.mass.cache.get(
743+
cache_key, category=CacheCategory.DEFAULT, base_key=self.lookup_key
744+
)
745+
746+
if cached_track_id:
747+
self.logger.debug(
748+
"Using cached track id",
749+
)
750+
try:
751+
return await get_track(tidal_session, str(cached_track_id))
752+
except MediaNotFoundError:
753+
# Track no longer exists, invalidate cache
754+
await self.mass.cache.delete(
755+
cache_key, category=CacheCategory.DEFAULT, base_key=self.lookup_key
756+
)
757+
758+
# Lookup by ISRC if no cache or cached track not found
759+
library_track = await self.mass.music.tracks.get_library_item_by_prov_id(
760+
item_id, self.instance_id
761+
)
762+
if not library_track:
763+
return None
764+
765+
isrc = next(
766+
(
767+
id_value
768+
for id_type, id_value in library_track.external_ids
769+
if id_type == ExternalID.ISRC
770+
),
771+
None,
772+
)
773+
if not isrc:
774+
return None
775+
776+
self.logger.debug("Attempting track lookup by ISRC: %s", isrc)
777+
tracks: list[TidalTrack] = await get_tracks_by_isrc(tidal_session, isrc)
778+
if not tracks:
779+
return None
780+
781+
# Cache the mapping for future use
782+
await self.mass.cache.set(
783+
cache_key, tracks[0].id, category=CacheCategory.DEFAULT, base_key=self.lookup_key
784+
)
785+
786+
return tracks[0]
787+
716788
# Parsers
717789

718790
def _parse_artist(self, artist_obj: TidalArtist) -> Artist:

music_assistant/providers/tidal/helpers.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
import logging
1414

1515
from music_assistant_models.enums import MediaType
16-
from music_assistant_models.errors import MediaNotFoundError, ResourceTemporarilyUnavailable
16+
from music_assistant_models.errors import (
17+
MediaNotFoundError,
18+
ResourceTemporarilyUnavailable,
19+
)
1720
from tidalapi import Album as TidalAlbum
1821
from tidalapi import Artist as TidalArtist
1922
from tidalapi import Favorites as TidalFavorites
@@ -22,7 +25,13 @@
2225
from tidalapi import Session as TidalSession
2326
from tidalapi import Track as TidalTrack
2427
from tidalapi import UserPlaylist as TidalUserPlaylist
25-
from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound, TooManyRequests
28+
from tidalapi.exceptions import (
29+
InvalidISRC,
30+
MetadataNotAvailable,
31+
ObjectNotFound,
32+
TooManyRequests,
33+
)
34+
from tidalapi.media import Lyrics as TidalLyrics
2635
from tidalapi.media import Stream as TidalStream
2736

2837
DEFAULT_LIMIT = 50
@@ -185,14 +194,38 @@ def inner() -> TidalTrack:
185194
return await asyncio.to_thread(inner)
186195

187196

188-
async def get_stream(track: TidalTrack) -> TidalStream:
189-
"""Async wrapper around the tidalapi Track.get_stream_url function."""
197+
async def get_track_lyrics(session: TidalSession, prov_track_id: str) -> TidalLyrics | None:
198+
"""Async wrapper around the tidalapi Track lyrics function."""
190199

191-
def inner() -> TidalStream:
200+
def inner() -> TidalLyrics | None:
192201
try:
193-
return track.get_stream()
202+
track: TidalTrack = TidalTrack(session, prov_track_id)
203+
lyrics = track.lyrics()
204+
if lyrics and hasattr(lyrics, "text"):
205+
return lyrics
194206
except ObjectNotFound as err:
195-
msg = f"Track {track.id} has no available stream"
207+
msg = f"Track {prov_track_id} not found"
208+
raise MediaNotFoundError(msg) from err
209+
except MetadataNotAvailable as err:
210+
msg = f"Lyrics not available for track {prov_track_id}"
211+
raise MediaNotFoundError(msg) from err
212+
except TooManyRequests:
213+
msg = "Tidal API rate limit reached"
214+
raise ResourceTemporarilyUnavailable(msg)
215+
return None
216+
217+
return await asyncio.to_thread(inner)
218+
219+
220+
async def get_tracks_by_isrc(session: TidalSession, isrc: str) -> list[TidalTrack]:
221+
"""Async wrapper around the tidalapi Track function."""
222+
223+
def inner() -> list[TidalTrack]:
224+
try:
225+
tracks: list[TidalTrack] = session.get_tracks_by_isrc(isrc)
226+
return tracks
227+
except InvalidISRC as err:
228+
msg = f"ISRC {isrc} invalid or not found"
196229
raise MediaNotFoundError(msg) from err
197230
except TooManyRequests:
198231
msg = "Tidal API rate limit reached"
@@ -201,15 +234,14 @@ def inner() -> TidalStream:
201234
return await asyncio.to_thread(inner)
202235

203236

204-
async def get_track_url(session: TidalSession, prov_track_id: str) -> str:
205-
"""Async wrapper around the tidalapi Track.get_url function."""
237+
async def get_stream(track: TidalTrack) -> TidalStream:
238+
"""Async wrapper around the tidalapi Track.get_stream_url function."""
206239

207-
def inner() -> str:
240+
def inner() -> TidalStream:
208241
try:
209-
track_url: str = TidalTrack(session, prov_track_id).get_url()
210-
return track_url
242+
return track.get_stream()
211243
except ObjectNotFound as err:
212-
msg = f"Track {prov_track_id} not found"
244+
msg = f"Track {track.id} has no available stream"
213245
raise MediaNotFoundError(msg) from err
214246
except TooManyRequests:
215247
msg = "Tidal API rate limit reached"

0 commit comments

Comments
 (0)