Handle unavailable media (#607)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 3 Apr 2023 07:45:21 +0000 (09:45 +0200)
committerGitHub <noreply@github.com>
Mon, 3 Apr 2023 07:45:21 +0000 (09:45 +0200)
* Handle changed provider ids

* correct existing providers

music_assistant/server/controllers/media/albums.py
music_assistant/server/controllers/media/artists.py
music_assistant/server/controllers/media/base.py
music_assistant/server/controllers/media/tracks.py
music_assistant/server/providers/plex/__init__.py
music_assistant/server/providers/qobuz/__init__.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/tunein/__init__.py
music_assistant/server/providers/ytmusic/__init__.py
music_assistant/server/server.py

index 59dc5ee5a37a9f640396af5fc958123b3df18de8..386a4f9f56e18b5129661e87e39a7665d657ab4d 100644 (file)
@@ -80,7 +80,9 @@ class AlbumsController(MediaControllerBase[Album]):
         """Add album to local db and return the database item."""
         # resolve any ItemMapping artists
         item.artists = [
-            await self.mass.music.artists.get_provider_item(artist.item_id, artist.provider)
+            await self.mass.music.artists.get_provider_item(
+                artist.item_id, artist.provider, fallback=artist
+            )
             if isinstance(artist, ItemMapping)
             else artist
             for artist in item.artists
@@ -398,7 +400,9 @@ class AlbumsController(MediaControllerBase[Album]):
                         continue
                     # we must fetch the full album version, search results are simplified objects
                     prov_album = await self.get_provider_item(
-                        search_result_item.item_id, search_result_item.provider
+                        search_result_item.item_id,
+                        search_result_item.provider,
+                        fallback=search_result_item,
                     )
                     if compare_album(prov_album, db_album):
                         # 100% match, we can simply update the db with additional provider ids
index 561d9769761f2bb015d8c8c9f3a7a2712c410d80..3e9ac019bc6a32a48e4d967e4b7e9eecaab80c83 100644 (file)
@@ -416,7 +416,9 @@ class ArtistsController(MediaControllerBase[Artist]):
                         # 100% album match
                         # get full artist details so we have all metadata
                         prov_artist = await self.get_provider_item(
-                            search_item_artist.item_id, search_item_artist.provider
+                            search_item_artist.item_id,
+                            search_item_artist.provider,
+                            fallback=search_result_item,
                         )
                         await self._update_db_item(db_artist.item_id, prov_artist)
                         return True
@@ -446,6 +448,7 @@ class ArtistsController(MediaControllerBase[Artist]):
                     prov_artist = await self.get_provider_item(
                         search_result_item.artists[0].item_id,
                         search_result_item.artists[0].provider,
+                        fallback=search_result_item,
                     )
                     await self._update_db_item(db_artist.item_id, prov_artist)
                     return True
index 3c5026624e4608d9dc8610a56192d490b14919f4..df4403a0bbb0d10aee22e8be3e0c21e70423ecf4 100644 (file)
@@ -378,7 +378,11 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item)
 
     async def get_provider_item(
-        self, item_id: str, provider_instance_id_or_domain: str, force_refresh: bool = False
+        self,
+        item_id: str,
+        provider_instance_id_or_domain: str,
+        force_refresh: bool = False,
+        fallback: ItemMapping | ItemCls = None,
     ) -> ItemCls:
         """Return item details for the given provider item id."""
         cache_key = (
@@ -388,23 +392,34 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
             return await self.get_db_item(item_id)
         if not force_refresh and (cache := await self.mass.cache.get(cache_key)):
             return self.item_cls.from_dict(cache)
-        if provider := self.mass.get_provider(provider_instance_id_or_domain):  # noqa: SIM102
-            item: MediaItemType = None
-            try:
-                item = await provider.get_item(self.media_type, item_id)
-            except MediaNotFoundError:
-                # fallback to domain matching
-                for provider in self.mass.music.providers:
-                    if not provider.available:
-                        continue
-                    if provider_instance_id_or_domain != provider.domain:
-                        continue
-                    with suppress(MediaNotFoundError):
-                        if item := await provider.get_item(self.media_type, item_id):
-                            break
-            if item:
-                await self.mass.cache.set(cache_key, item.to_dict())
-                return item
+        if provider := self.mass.get_provider(provider_instance_id_or_domain):
+            with suppress(MediaNotFoundError):
+                if item := await provider.get_item(self.media_type, item_id):
+                    await self.mass.cache.set(cache_key, item.to_dict())
+                    return item
+        # if we reach this point all possibilities failed and the item could not be found.
+        # There is a possibility that the (streaming) provider changed the id of the item
+        # so we return the previous details (if we have any) marked as unavailable, so
+        # at least we have the possibility to sort out the new id through matching logic.
+        if not fallback:
+            fallback = await self.get_db_item_by_prov_id(item_id, provider_instance_id_or_domain)
+        if fallback:
+            fallback_result = ItemCls(
+                item_id=item_id,
+                provider=provider.instance_id,
+                name=fallback.name,
+                provider_mappings={
+                    ProviderMapping(
+                        item_id=item_id,
+                        provider_domain=provider.domain,
+                        provider_instance=provider.instance_id,
+                        available=False,
+                    )
+                },
+            )
+            if hasattr(fallback, "version") and hasattr(fallback_result, "version"):
+                fallback_result.version = fallback.version
+            return fallback_result
         raise MediaNotFoundError(
             f"{self.media_type.value}://{item_id} not "
             "found on provider {provider_instance_id_or_domain}"
index 67799ecffbcf94a3e106e6db1175396cc9c238cc..bee2b76fff4078440df1a6625e7bafda27eda952 100644 (file)
@@ -99,7 +99,9 @@ class TracksController(MediaControllerBase[Track]):
         assert item.artists
         # resolve any ItemMapping artists
         item.artists = [
-            await self.mass.music.artists.get_provider_item(artist.item_id, artist.provider)
+            await self.mass.music.artists.get_provider_item(
+                artist.item_id, artist.provider, fallback=artist
+            )
             if isinstance(artist, ItemMapping)
             else artist
             for artist in item.artists
@@ -107,11 +109,13 @@ class TracksController(MediaControllerBase[Track]):
         # resolve ItemMapping album
         if isinstance(item.album, ItemMapping):
             item.album = await self.mass.music.albums.get_provider_item(
-                item.album.item_id, item.album.provider
+                item.album.item_id, item.album.provider, fallback=item.album
             )
         if item.album:
             item.album.artists = [
-                await self.mass.music.artists.get_provider_item(artist.item_id, artist.provider)
+                await self.mass.music.artists.get_provider_item(
+                    artist.item_id, artist.provider, fallback=artist
+                )
                 if isinstance(artist, ItemMapping)
                 else artist
                 for artist in item.album.artists
@@ -217,7 +221,9 @@ class TracksController(MediaControllerBase[Track]):
                         continue
                     # we must fetch the full album version, search results are simplified objects
                     prov_track = await self.get_provider_item(
-                        search_result_item.item_id, search_result_item.provider
+                        search_result_item.item_id,
+                        search_result_item.provider,
+                        fallback=search_result_item,
                     )
                     if compare_track(prov_track, db_track):
                         # 100% match, we can simply update the db with additional provider ids
index 74355cf2ad28cc5881c76bf2bfed918b603d1d81..1518b302d8691c5debe9996dbf5e30e126e5497c 100644 (file)
@@ -402,8 +402,9 @@ class PlexProvider(MusicProvider):
 
     async def get_album(self, prov_album_id) -> Album:
         """Get full album details by id."""
-        plex_album = await self._get_data(prov_album_id, PlexAlbum)
-        return await self._parse_album(plex_album) if plex_album else None
+        if plex_album := await self._get_data(prov_album_id, PlexAlbum):
+            return await self._parse_album(plex_album)
+        raise MediaNotFoundError(f"Item {prov_album_id} not found")
 
     async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
         """Get album tracks for given album id."""
@@ -417,18 +418,21 @@ class PlexProvider(MusicProvider):
 
     async def get_artist(self, prov_artist_id) -> Artist:
         """Get full artist details by id."""
-        plex_artist = await self._get_data(prov_artist_id, PlexArtist)
-        return await self._parse_artist(plex_artist) if plex_artist else None
+        if plex_artist := await self._get_data(prov_artist_id, PlexArtist):
+            return await self._parse_artist(plex_artist)
+        raise MediaNotFoundError(f"Item {prov_artist_id} not found")
 
     async def get_track(self, prov_track_id) -> Track:
         """Get full track details by id."""
-        plex_track = await self._get_data(prov_track_id, PlexTrack)
-        return await self._parse_track(plex_track)
+        if plex_track := await self._get_data(prov_track_id, PlexTrack):
+            return await self._parse_track(plex_track)
+        raise MediaNotFoundError(f"Item {prov_track_id} not found")
 
     async def get_playlist(self, prov_playlist_id) -> Playlist:
         """Get full playlist details by id."""
-        plex_playlist = await self._get_data(prov_playlist_id, PlexPlaylist)
-        return await self._parse_playlist(plex_playlist)
+        if plex_playlist := await self._get_data(prov_playlist_id, PlexPlaylist):
+            return await self._parse_playlist(plex_playlist)
+        raise MediaNotFoundError(f"Item {prov_playlist_id} not found")
 
     async def get_playlist_tracks(  # type: ignore[return]
         self, prov_playlist_id: str
index 671373ec5f220afa06a9237211a06116c5160361..eb361b8f9191c11bd40c5f80cbccd914a2e0ffb5 100644 (file)
@@ -181,30 +181,30 @@ class QobuzProvider(MusicProvider):
     async def get_artist(self, prov_artist_id) -> Artist:
         """Get full artist details by id."""
         params = {"artist_id": prov_artist_id}
-        artist_obj = await self._get_data("artist/get", **params)
-        return await self._parse_artist(artist_obj) if artist_obj and artist_obj["id"] else None
+        if (artist_obj := await self._get_data("artist/get", **params)) and artist_obj["id"]:
+            return await self._parse_artist(artist_obj)
+        raise MediaNotFoundError(f"Item {prov_artist_id} not found")
 
     async def get_album(self, prov_album_id) -> Album:
         """Get full album details by id."""
         params = {"album_id": prov_album_id}
-        album_obj = await self._get_data("album/get", **params)
-        return await self._parse_album(album_obj) if album_obj and album_obj["id"] else None
+        if (album_obj := await self._get_data("album/get", **params)) and album_obj["id"]:
+            return await self._parse_album(album_obj)
+        raise MediaNotFoundError(f"Item {prov_album_id} not found")
 
     async def get_track(self, prov_track_id) -> Track:
         """Get full track details by id."""
         params = {"track_id": prov_track_id}
-        track_obj = await self._get_data("track/get", **params)
-        return await self._parse_track(track_obj) if track_obj and track_obj["id"] else None
+        if (track_obj := await self._get_data("track/get", **params)) and track_obj["id"]:
+            return await self._parse_track(track_obj)
+        raise MediaNotFoundError(f"Item {prov_track_id} not found")
 
     async def get_playlist(self, prov_playlist_id) -> Playlist:
         """Get full playlist details by id."""
         params = {"playlist_id": prov_playlist_id}
-        playlist_obj = await self._get_data("playlist/get", **params)
-        return (
-            await self._parse_playlist(playlist_obj)
-            if playlist_obj and playlist_obj["id"]
-            else None
-        )
+        if (playlist_obj := await self._get_data("playlist/get", **params)) and playlist_obj["id"]:
+            return await self._parse_playlist(playlist_obj)
+        raise MediaNotFoundError(f"Item {prov_playlist_id} not found")
 
     async def get_album_tracks(self, prov_album_id) -> list[Track]:
         """Get all album tracks for given album id."""
index c528d7ab4e35fc4e9fb689d27a2d5fc607b3960b..b3fb357480f5c0b1aabed18d2ad6f2324b3b589d 100644 (file)
@@ -218,18 +218,21 @@ class SpotifyProvider(MusicProvider):
 
     async def get_album(self, prov_album_id) -> Album:
         """Get full album details by id."""
-        album_obj = await self._get_data(f"albums/{prov_album_id}")
-        return await self._parse_album(album_obj) if album_obj else None
+        if album_obj := await self._get_data(f"albums/{prov_album_id}"):
+            return await self._parse_album(album_obj)
+        raise MediaNotFoundError(f"Item {prov_album_id} not found")
 
     async def get_track(self, prov_track_id) -> Track:
         """Get full track details by id."""
-        track_obj = await self._get_data(f"tracks/{prov_track_id}")
-        return await self._parse_track(track_obj) if track_obj else None
+        if track_obj := await self._get_data(f"tracks/{prov_track_id}"):
+            return await self._parse_track(track_obj)
+        raise MediaNotFoundError(f"Item {prov_track_id} not found")
 
     async def get_playlist(self, prov_playlist_id) -> Playlist:
         """Get full playlist details by id."""
-        playlist_obj = await self._get_data(f"playlists/{prov_playlist_id}")
-        return await self._parse_playlist(playlist_obj) if playlist_obj else None
+        if playlist_obj := await self._get_data(f"playlists/{prov_playlist_id}"):
+            return await self._parse_playlist(playlist_obj)
+        raise MediaNotFoundError(f"Item {prov_playlist_id} not found")
 
     async def get_album_tracks(self, prov_album_id) -> list[Track]:
         """Get all album tracks for given album id."""
index d17dfbf02d168d86ab7d932e986ddf2ab58ea09f..a3084d81bcdf9124db9c9438dee251de73d2329b 100644 (file)
@@ -128,7 +128,7 @@ class TuneInProvider(MusicProvider):
         async for radio in self.get_library_radios():
             if radio.item_id == prov_radio_id:
                 return radio
-        return None
+        raise MediaNotFoundError(f"Item {prov_radio_id} not found")
 
     async def _parse_radio(
         self, details: dict, stream: dict | None = None, folder: str | None = None
index 375aa7b66976a9f05583711e5b505dcdfa3f1de3..2eeca05e9b6986f9e41a234ad417916b83ef9dc8 100644 (file)
@@ -213,12 +213,9 @@ class YoutubeMusicProvider(MusicProvider):
 
     async def get_album(self, prov_album_id) -> Album:
         """Get full album details by id."""
-        album_obj = await get_album(prov_album_id=prov_album_id)
-        return (
-            await self._parse_album(album_obj=album_obj, album_id=prov_album_id)
-            if album_obj
-            else None
-        )
+        if album_obj := await get_album(prov_album_id=prov_album_id):
+            return await self._parse_album(album_obj=album_obj, album_id=prov_album_id)
+        raise MediaNotFoundError(f"Item {prov_album_id} not found")
 
     async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
         """Get album tracks for given album id."""
@@ -235,22 +232,25 @@ class YoutubeMusicProvider(MusicProvider):
 
     async def get_artist(self, prov_artist_id) -> Artist:
         """Get full artist details by id."""
-        artist_obj = await get_artist(prov_artist_id=prov_artist_id)
-        return await self._parse_artist(artist_obj=artist_obj) if artist_obj else None
+        if artist_obj := await get_artist(prov_artist_id=prov_artist_id):
+            return await self._parse_artist(artist_obj=artist_obj)
+        raise MediaNotFoundError(f"Item {prov_artist_id} not found")
 
     async def get_track(self, prov_track_id) -> Track:
         """Get full track details by id."""
-        track_obj = await get_track(prov_track_id=prov_track_id)
-        return await self._parse_track(track_obj)
+        if track_obj := await get_track(prov_track_id=prov_track_id):
+            return await self._parse_track(track_obj)
+        raise MediaNotFoundError(f"Item {prov_track_id} not found")
 
     async def get_playlist(self, prov_playlist_id) -> Playlist:
         """Get full playlist details by id."""
-        playlist_obj = await get_playlist(
+        if playlist_obj := await get_playlist(
             prov_playlist_id=prov_playlist_id,
             headers=self._headers,
             username=self.config.get_value(CONF_USERNAME),
-        )
-        return await self._parse_playlist(playlist_obj)
+        ):
+            return await self._parse_playlist(playlist_obj)
+        raise MediaNotFoundError(f"Item {prov_playlist_id} not found")
 
     async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[Track, None]:
         """Get all playlist tracks for given playlist id."""
index 49eb0fce4098c5fa2435254b32b19bc2dc7f965b..76b1438a0280cebd5b96c295a54cb3bc8d87e920 100644 (file)
@@ -155,10 +155,16 @@ class MusicAssistant:
     def get_provider(
         self, provider_instance_or_domain: str, return_unavailable: bool = False
     ) -> ProviderInstanceType | None:
-        """Return provider by instance id (or domain)."""
-        prov = self._providers.get(provider_instance_or_domain)
-        if prov is not None and (return_unavailable or prov.available):
-            return prov
+        """Return provider by instance id or domain."""
+        # lookup by instance_id first
+        if prov := self._providers.get(provider_instance_or_domain):
+            if return_unavailable or prov.available:
+                return prov
+            if prov.is_unique:
+                # no need to lookup other instances because this provider has unique data
+                return None
+            provider_instance_or_domain = prov.domain
+        # fallback to match on domain
         for prov in self._providers.values():
             if prov.domain != provider_instance_or_domain:
                 continue