Mypy and track fallback fixes for Tidal provider (#1926)
authorJozef Kruszynski <60214390+jozefKruszynski@users.noreply.github.com>
Fri, 31 Jan 2025 21:12:48 +0000 (22:12 +0100)
committerGitHub <noreply@github.com>
Fri, 31 Jan 2025 21:12:48 +0000 (22:12 +0100)
* refactor: cleanup based on mypy

* feat: add fallback track lookup by isrc

* feat: add cache to isrc lookup

music_assistant/providers/tidal/__init__.py
music_assistant/providers/tidal/helpers.py

index b337c05eec9b7abaddf7c712d5ebb2e8cd1170a2..47529301784af537bba37d4d12b6cae4c7ad6a0c 100644 (file)
@@ -76,6 +76,8 @@ from .helpers import (
     get_similar_tracks,
     get_stream,
     get_track,
+    get_track_lyrics,
+    get_tracks_by_isrc,
     library_items_add_remove,
     remove_playlist_tracks,
     search,
@@ -545,20 +547,24 @@ class TidalProvider(MusicProvider):
     async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
         """Add track(s) to playlist."""
         tidal_session = await self._get_tidal_session()
-        return await add_playlist_tracks(tidal_session, prov_playlist_id, prov_track_ids)
+        await add_playlist_tracks(tidal_session, prov_playlist_id, prov_track_ids)
 
     async def remove_playlist_tracks(
         self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
     ) -> None:
         """Remove track(s) from playlist."""
-        prov_track_ids = []
         tidal_session = await self._get_tidal_session()
+        prov_track_ids: list[str] = []
+        # Get tracks by position
         for pos in positions_to_remove:
-            for tidal_track in await get_playlist_tracks(
+            tracks = await get_playlist_tracks(
                 tidal_session, prov_playlist_id, limit=1, offset=pos - 1
-            ):
-                prov_track_ids.append(tidal_track.id)
-        return await remove_playlist_tracks(tidal_session, prov_playlist_id, prov_track_ids)
+            )
+            if tracks and len(tracks) > 0:
+                prov_track_ids.append(str(tracks[0].id))
+
+        if prov_track_ids:
+            await remove_playlist_tracks(tidal_session, prov_playlist_id, prov_track_ids)
 
     async def create_playlist(self, name: str) -> Playlist:
         """Create a new playlist on provider with given name."""
@@ -577,17 +583,30 @@ class TidalProvider(MusicProvider):
         """Return the content details for the given track when it will be streamed."""
         tidal_session = await self._get_tidal_session()
         # make sure a valid track is requested.
-        if not (track := await get_track(tidal_session, item_id)):
-            msg = f"track {item_id} not found"
-            raise MediaNotFoundError(msg)
+        # Try direct track lookup first with exception handling
+        try:
+            track = await get_track(tidal_session, item_id)
+        except MediaNotFoundError:
+            # Fallback to ISRC lookup
+            self.logger.info(
+                """Track %s not found, attempting fallback by ISRC.
+                It's likely that this track has a new ID upstream in Tidal's WebApp.""",
+                item_id,
+            )
+            track = await self._get_track_by_isrc(item_id, tidal_session)
+            if not track:
+                raise MediaNotFoundError(f"Track {item_id} not found")
+
         stream: TidalStream = await get_stream(track)
         manifest = stream.get_stream_manifest()
-        if stream.is_mpd:
+
+        url = (
             # for mpeg-dash streams we just pass the complete base64 manifest
-            url = f"data:application/dash+xml;base64,{manifest.manifest}"
-        else:
+            f"data:application/dash+xml;base64,{manifest.manifest}"
+            if stream.is_mpd
             # as far as I can oversee a BTS stream is just a single URL
-            url = manifest.urls[0]
+            else manifest.urls[0]
+        )
 
         return StreamDetails(
             item_id=track.id,
@@ -632,8 +651,9 @@ class TidalProvider(MusicProvider):
             track = self._parse_track(track_obj)
             # get some extra details for the full track info
             with suppress(tidal_exceptions.MetadataNotAvailable, AttributeError):
-                lyrics: TidalLyrics = await asyncio.to_thread(track_obj.lyrics)
-                track.metadata.lyrics = lyrics.text
+                lyrics: TidalLyrics = await get_track_lyrics(tidal_session, prov_track_id)
+                if lyrics and hasattr(lyrics, "text"):
+                    track.metadata.lyrics = lyrics.text
             return track
         except tidal_exceptions.ObjectNotFound as err:
             raise MediaNotFoundError from err
@@ -713,6 +733,58 @@ class TidalProvider(MusicProvider):
 
         return await asyncio.to_thread(inner)
 
+    async def _get_track_by_isrc(
+        self, item_id: str, tidal_session: TidalSession
+    ) -> TidalTrack | None:
+        """Get track by ISRC from library item, with caching."""
+        # Try to get from cache first
+        cache_key = f"isrc_map_{item_id}"
+        cached_track_id = await self.mass.cache.get(
+            cache_key, category=CacheCategory.DEFAULT, base_key=self.lookup_key
+        )
+
+        if cached_track_id:
+            self.logger.debug(
+                "Using cached track id",
+            )
+            try:
+                return await get_track(tidal_session, str(cached_track_id))
+            except MediaNotFoundError:
+                # Track no longer exists, invalidate cache
+                await self.mass.cache.delete(
+                    cache_key, category=CacheCategory.DEFAULT, base_key=self.lookup_key
+                )
+
+        # Lookup by ISRC if no cache or cached track not found
+        library_track = await self.mass.music.tracks.get_library_item_by_prov_id(
+            item_id, self.instance_id
+        )
+        if not library_track:
+            return None
+
+        isrc = next(
+            (
+                id_value
+                for id_type, id_value in library_track.external_ids
+                if id_type == ExternalID.ISRC
+            ),
+            None,
+        )
+        if not isrc:
+            return None
+
+        self.logger.debug("Attempting track lookup by ISRC: %s", isrc)
+        tracks: list[TidalTrack] = await get_tracks_by_isrc(tidal_session, isrc)
+        if not tracks:
+            return None
+
+        # Cache the mapping for future use
+        await self.mass.cache.set(
+            cache_key, tracks[0].id, category=CacheCategory.DEFAULT, base_key=self.lookup_key
+        )
+
+        return tracks[0]
+
     # Parsers
 
     def _parse_artist(self, artist_obj: TidalArtist) -> Artist:
index 9dba4d70a329204fa749137cc14f8291bb965893..8780821863d01062133057e43a33f262ee82a9df 100644 (file)
@@ -13,7 +13,10 @@ import asyncio
 import logging
 
 from music_assistant_models.enums import MediaType
-from music_assistant_models.errors import MediaNotFoundError, ResourceTemporarilyUnavailable
+from music_assistant_models.errors import (
+    MediaNotFoundError,
+    ResourceTemporarilyUnavailable,
+)
 from tidalapi import Album as TidalAlbum
 from tidalapi import Artist as TidalArtist
 from tidalapi import Favorites as TidalFavorites
@@ -22,7 +25,13 @@ from tidalapi import Playlist as TidalPlaylist
 from tidalapi import Session as TidalSession
 from tidalapi import Track as TidalTrack
 from tidalapi import UserPlaylist as TidalUserPlaylist
-from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound, TooManyRequests
+from tidalapi.exceptions import (
+    InvalidISRC,
+    MetadataNotAvailable,
+    ObjectNotFound,
+    TooManyRequests,
+)
+from tidalapi.media import Lyrics as TidalLyrics
 from tidalapi.media import Stream as TidalStream
 
 DEFAULT_LIMIT = 50
@@ -185,14 +194,38 @@ async def get_track(session: TidalSession, prov_track_id: str) -> TidalTrack:
     return await asyncio.to_thread(inner)
 
 
-async def get_stream(track: TidalTrack) -> TidalStream:
-    """Async wrapper around the tidalapi Track.get_stream_url function."""
+async def get_track_lyrics(session: TidalSession, prov_track_id: str) -> TidalLyrics | None:
+    """Async wrapper around the tidalapi Track lyrics function."""
 
-    def inner() -> TidalStream:
+    def inner() -> TidalLyrics | None:
         try:
-            return track.get_stream()
+            track: TidalTrack = TidalTrack(session, prov_track_id)
+            lyrics = track.lyrics()
+            if lyrics and hasattr(lyrics, "text"):
+                return lyrics
         except ObjectNotFound as err:
-            msg = f"Track {track.id} has no available stream"
+            msg = f"Track {prov_track_id} not found"
+            raise MediaNotFoundError(msg) from err
+        except MetadataNotAvailable as err:
+            msg = f"Lyrics not available for track {prov_track_id}"
+            raise MediaNotFoundError(msg) from err
+        except TooManyRequests:
+            msg = "Tidal API rate limit reached"
+            raise ResourceTemporarilyUnavailable(msg)
+        return None
+
+    return await asyncio.to_thread(inner)
+
+
+async def get_tracks_by_isrc(session: TidalSession, isrc: str) -> list[TidalTrack]:
+    """Async wrapper around the tidalapi Track function."""
+
+    def inner() -> list[TidalTrack]:
+        try:
+            tracks: list[TidalTrack] = session.get_tracks_by_isrc(isrc)
+            return tracks
+        except InvalidISRC as err:
+            msg = f"ISRC {isrc} invalid or not found"
             raise MediaNotFoundError(msg) from err
         except TooManyRequests:
             msg = "Tidal API rate limit reached"
@@ -201,15 +234,14 @@ async def get_stream(track: TidalTrack) -> TidalStream:
     return await asyncio.to_thread(inner)
 
 
-async def get_track_url(session: TidalSession, prov_track_id: str) -> str:
-    """Async wrapper around the tidalapi Track.get_url function."""
+async def get_stream(track: TidalTrack) -> TidalStream:
+    """Async wrapper around the tidalapi Track.get_stream_url function."""
 
-    def inner() -> str:
+    def inner() -> TidalStream:
         try:
-            track_url: str = TidalTrack(session, prov_track_id).get_url()
-            return track_url
+            return track.get_stream()
         except ObjectNotFound as err:
-            msg = f"Track {prov_track_id} not found"
+            msg = f"Track {track.id} has no available stream"
             raise MediaNotFoundError(msg) from err
         except TooManyRequests:
             msg = "Tidal API rate limit reached"