A few small bugfixes (#818)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 4 Aug 2023 00:18:22 +0000 (02:18 +0200)
committerGitHub <noreply@github.com>
Fri, 4 Aug 2023 00:18:22 +0000 (02:18 +0200)
* Make sure that file/library items never get cached

* fix typo

* fix end of queue reached in cast provider + title

* Prefer album image for tracks

* fix keyerror on non existent YTmusic track

* Fix enqueueing logic

* prevent enqueue next track if MA is not the active source

* prevent unmount log at startup

* cleanup only on coordinator

* fix typo

* fix typo

* use flac as intermediate codec to airplay bridge

15 files changed:
music_assistant/common/models/media_items.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/controllers/music.py
music_assistant/server/controllers/player_queues.py
music_assistant/server/models/music_provider.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/dlna/__init__.py
music_assistant/server/providers/filesystem_smb/__init__.py
music_assistant/server/providers/plex/__init__.py
music_assistant/server/providers/sonos/__init__.py
music_assistant/server/providers/ytmusic/__init__.py
music_assistant/server/providers/ytmusic/helpers.py

index 8cdd22bdc2aee0ab8e8e3d2d95e84d65cb3f70ee..06f26a1afd8b7bb443fb2e3155b466880226f349 100755 (executable)
@@ -330,6 +330,13 @@ class Track(MediaItem):
         """
         return self.metadata and self.metadata.chapters and len(self.metadata.chapters) > 1
 
+    @property
+    def image(self) -> MediaItemImage | None:
+        """Return (first) image from metadata (prefer album)."""
+        if isinstance(self.album, Album) and self.album.image:
+            return self.album.image
+        return super().image
+
 
 @dataclass(kw_only=True)
 class AlbumTrack(Track):
index dca0b5c7f383e831dcb03028ac0db432c8ae20a4..0cee628d29b2266a1e4e8c2365133466c1a87e4b 100644 (file)
@@ -62,7 +62,7 @@ class ArtistsController(MediaControllerBase[Artist]):
     ) -> Artist:
         """Add artist to library and return the database item."""
         if isinstance(item, ItemMapping):
-            metadata_lookup = True
+            metadata_lookup = False
         # grab musicbrainz id and additional metadata
         if metadata_lookup:
             await self.mass.metadata.get_artist_metadata(item)
@@ -232,6 +232,7 @@ class ArtistsController(MediaControllerBase[Artist]):
         provider_instance_id_or_domain: str,
     ) -> list[Track]:
         """Return top tracks for an artist on given provider."""
+        items = []
         assert provider_instance_id_or_domain != "library"
         artist = await self.get(item_id, provider_instance_id_or_domain, add_to_library=False)
         cache_checksum = artist.metadata.checksum
@@ -247,7 +248,6 @@ class ArtistsController(MediaControllerBase[Artist]):
             items = await prov.get_artist_toptracks(item_id)
         else:
             # fallback implementation using the db
-            items = []
             if db_artist := await self.mass.music.artists.get_library_item_by_prov_id(
                 item_id,
                 provider_instance_id_or_domain,
@@ -257,8 +257,8 @@ class ArtistsController(MediaControllerBase[Artist]):
                 query += (
                     f" AND tracks.provider_mappings LIKE '%\"{provider_instance_id_or_domain}\"%'"
                 )
-                if paged_list := await self.mass.music.tracks.library_items(extra_query=query):
-                    items = paged_list.items
+                paged_list = await self.mass.music.tracks.library_items(extra_query=query)
+                return paged_list.items
         # store (serializable items) in cache
         self.mass.create_task(
             self.mass.cache.set(cache_key, [x.to_dict() for x in items], checksum=cache_checksum)
@@ -281,6 +281,7 @@ class ArtistsController(MediaControllerBase[Artist]):
         provider_instance_id_or_domain: str,
     ) -> list[Album]:
         """Return albums for an artist on given provider."""
+        items = []
         assert provider_instance_id_or_domain != "library"
         artist = await self.get_provider_item(item_id, provider_instance_id_or_domain)
         cache_checksum = artist.metadata.checksum
@@ -307,10 +308,7 @@ class ArtistsController(MediaControllerBase[Artist]):
                     f" AND albums.provider_mappings LIKE '%\"{provider_instance_id_or_domain}\"%'"
                 )
                 paged_list = await self.mass.music.albums.library_items(extra_query=query)
-                items = paged_list.items
-            else:
-                # edge case
-                items = []
+                return paged_list.items
         # store (serializable items) in cache
         self.mass.create_task(
             self.mass.cache.set(cache_key, [x.to_dict() for x in items], checksum=cache_checksum)
index 54345cd356d3f4e2c3c8572b03dd566aee981a01..32e82d13ba59cf990803120f2978d1f255b69bbe 100644 (file)
@@ -253,7 +253,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         else:
             items = searchresult.radio
         # store (serializable items) in cache
-        if not prov.domain.startswith("filesystem"):  # do not cache filesystem results
+        if prov.is_streaming_provider:  # do not cache filesystem results
             self.mass.create_task(
                 self.mass.cache.set(cache_key, [x.to_dict() for x in items], expiration=86400 * 7)
             )
@@ -717,8 +717,4 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
                 "version": db_row_dict["album_version"],
             }
             db_row_dict["album"] = ItemMapping.from_dict(db_row_dict["album"])
-            if not db_row_dict["metadata"]["images"]:
-                # copy album image in case the track has no image
-                album_metadata = json_loads(db_row_dict["album_metadata"])
-                db_row_dict["metadata"]["images"] = album_metadata["images"]
         return db_row_dict
index b81f9301914edfbff3bc4b60e72806f1b5179f50..fe4e864203e6afdf53c0975007b92cbd690b611c 100644 (file)
@@ -130,7 +130,7 @@ class TracksController(MediaControllerBase[Track]):
         # grab additional metadata
         if metadata_lookup:
             await self.mass.metadata.get_track_metadata(item)
-        # fallback track image from album (only if albumtype = single)
+        # allow track image from album (only if albumtype = single)
         if (
             not item.image
             and isinstance(item.album, Album)
index d636e13260991b9aeb20ab5a3547614f4405600f..0b2605f7e47a054c004dfe6b7cf00f593a696170 100755 (executable)
@@ -239,7 +239,7 @@ class MusicController(CoreController):
         cache_key = f"{prov.instance_id}.search.{search_query}.{limit}.{media_types_str}"
         cache_key += "".join(x for x in media_types)
 
-        if cache := await self.mass.cache.get(cache_key):
+        if prov.is_streaming_provider and (cache := await self.mass.cache.get(cache_key)):
             return SearchResults.from_dict(cache)
         # no items in cache - get listing from provider
         result = await prov.search(
@@ -248,9 +248,10 @@ class MusicController(CoreController):
             limit,
         )
         # store (serializable items) in cache
-        self.mass.create_task(
-            self.mass.cache.set(cache_key, result.to_dict(), expiration=86400 * 7)
-        )
+        if prov.is_streaming_provider:
+            self.mass.create_task(
+                self.mass.cache.set(cache_key, result.to_dict(), expiration=86400 * 7)
+            )
         return result
 
     @api_command("music/browse")
index ac3703b63862cd8cdb738c81ed7027c3292d7b66..e363d23c4a6e20d8e5e85169998a77aafde0efa3 100755 (executable)
@@ -245,21 +245,29 @@ class PlayerQueuesController(CoreController):
             cur_index = queue.index_in_buffer or 0
         else:
             cur_index = queue.current_index or 0
-        shuffle = queue.shuffle_enabled and len(queue_items) >= 5
+        shuffle = queue.shuffle_enabled and len(queue_items) > 1
 
         # handle replace: clear all items and replace with the new items
         if option == QueueOption.REPLACE:
-            self.load(queue_id, queue_items=queue_items, shuffle=shuffle)
+            self.load(
+                queue_id,
+                queue_items=queue_items,
+                keep_remaining=False,
+                keep_played=False,
+                shuffle=shuffle,
+            )
             await self.play_index(queue_id, 0)
+            return
         # handle next: add item(s) in the index next to the playing/loaded/buffered index
-        elif option == QueueOption.NEXT:
+        if option == QueueOption.NEXT:
             self.load(
                 queue_id,
                 queue_items=queue_items,
                 insert_at_index=cur_index + 1,
                 shuffle=shuffle,
             )
-        elif option == QueueOption.REPLACE_NEXT:
+            return
+        if option == QueueOption.REPLACE_NEXT:
             self.load(
                 queue_id,
                 queue_items=queue_items,
@@ -267,19 +275,20 @@ class PlayerQueuesController(CoreController):
                 keep_remaining=False,
                 shuffle=shuffle,
             )
+            return
         # handle play: replace current loaded/playing index with new item(s)
-        elif option == QueueOption.PLAY:
-            if cur_index <= len(self._queue_items[queue_id]) - 1:
-                cur_index = 0
+        if option == QueueOption.PLAY:
             self.load(
                 queue_id,
                 queue_items=queue_items,
-                insert_at_index=cur_index,
+                insert_at_index=cur_index + 1,
                 shuffle=shuffle,
             )
-            await self.play_index(queue_id, cur_index)
+            next_index = min(cur_index + 1, len(self._queue_items[queue_id]) - 1)
+            await self.play_index(queue_id, next_index)
+            return
         # handle add: add/append item(s) to the remaining queue items
-        elif option == QueueOption.ADD:
+        if option == QueueOption.ADD:
             if queue.shuffle_enabled:
                 # shuffle the new items with remaining queue items
                 insert_at_index = cur_index + 1
@@ -744,6 +753,7 @@ class PlayerQueuesController(CoreController):
         queue_items: list[QueueItem],
         insert_at_index: int = 0,
         keep_remaining: bool = True,
+        keep_played: bool = True,
         shuffle: bool = False,
     ) -> None:
         """Load new items at index.
@@ -754,11 +764,10 @@ class PlayerQueuesController(CoreController):
         - keep_remaining: keep the remaining items after the insert
         - shuffle: (re)shuffle the items after insert index
         """
-        # keep previous/played items, append the new ones
-        prev_items = self._queue_items[queue_id][:insert_at_index]
+        prev_items = self._queue_items[queue_id][:insert_at_index] if keep_played else []
         next_items = queue_items
 
-        # if keep_remaining, append the old previous items
+        # if keep_remaining, append the old 'next' items
         if keep_remaining:
             next_items += self._queue_items[queue_id][insert_at_index:]
 
index aa7e6177d540434ed29da4f1b2cf302efa02f966..944d119ec6ffdc83f4ec0306761cc690a3d573c5 100644 (file)
@@ -412,7 +412,7 @@ class MusicProvider(Provider):
                 try:
                     if not library_item and not prov_item.available:
                         # skip unavailable tracks
-                        self.logger.debg(
+                        self.logger.debug(
                             "Skipping sync of item %s because it is unavailable", prov_item.uri
                         )
                         continue
index 0fb82ed90a0cd68cd84420a958147a337f76fbfc..6030e3a1be24be70d399a41051e1789fb68fe32d 100644 (file)
@@ -75,7 +75,7 @@ PLAYER_CONFIG_ENTRIES = (
         advanced=True,
     ),
     ConfigEntry.from_dict(
-        {**CONF_ENTRY_OUTPUT_CODEC.to_dict(), "default_value": "pcm", "hidden": True}
+        {**CONF_ENTRY_OUTPUT_CODEC.to_dict(), "default_value": "flac", "hidden": True}
     ),
 )
 
@@ -431,9 +431,9 @@ class AirplayProvider(PlayerProvider):
 
         # set codecs and sample rate to airplay default
         common_elem = xml_root.find("common")
-        common_elem.find("codecs").text = "pcm"
+        common_elem.find("codecs").text = "flc,pcm"
         common_elem.find("sample_rate").text = "44100"
-        common_elem.find("resample").text = "0"
+        common_elem.find("resample").text = "1"
         common_elem.find("player_volume").text = "20"
 
         # default values for players
index 212c5d233a478e08239105de627dbcd9d6526628..36c9b8c90f5300ad5b7e4d4dcce12068dd681364 100644 (file)
@@ -441,10 +441,20 @@ class ChromecastProvider(PlayerProvider):
         self.mass.loop.call_soon_threadsafe(self.mass.players.update, castplayer.player_id)
 
         # enqueue next item if needed
-        if castplayer.player.state == PlayerState.PLAYING and (
-            not castplayer.next_url or castplayer.next_url == castplayer.player.current_url
+        if (
+            castplayer.player.state == PlayerState.PLAYING
+            and castplayer.player.active_source == castplayer.player.player_id
+            and castplayer.next_url in (None, castplayer.player.current_url)
         ):
             asyncio.run_coroutine_threadsafe(self._enqueue_next_track(castplayer), self.mass.loop)
+        # handle end of MA queue - set current item to None
+        if (
+            castplayer.player.state == PlayerState.IDLE
+            and castplayer.player.current_url
+            and (queue := self.mass.player_queues.get(castplayer.player_id))
+            and queue.next_item is None
+        ):
+            castplayer.player.current_url = None
 
     def on_new_connection_status(self, castplayer: CastPlayer, status: ConnectionStatus) -> None:
         """Handle updated ConnectionStatus."""
@@ -583,7 +593,7 @@ class ChromecastProvider(PlayerProvider):
                 "artist": queue_item.media_item.artists[0].name
                 if queue_item.media_item.artists
                 else "",
-                "title": queue_item.name,
+                "title": queue_item.media_item.name,
                 "images": [{"url": image_url}] if image_url else None,
             }
         else:
index e2c9cb26b24905f6bbd2f9aa5378a8d9c7fbc0a5..7db38698fc8302b84de9958b4337205c5837fd07 100644 (file)
@@ -633,8 +633,8 @@ class DLNAPlayerProvider(PlayerProvider):
         # enqueue next item if needed
         if (
             dlna_player.player.state == PlayerState.PLAYING
-            and dlna_player.player.player_id in current_url
-            and (not dlna_player.next_url or dlna_player.next_url == current_url)
+            and dlna_player.player.active_source == dlna_player.player.player_id
+            and dlna_player.next_url in (None, dlna_player.player.current_url)
             # prevent race conditions at start/stop by doing this check
             and (time.time() - dlna_player.last_command) > 4
         ):
index 9f0537b47b7bf7954da922460153001347264374..1641eebb198248a2251f88e13e2630c938215686 100644 (file)
@@ -136,7 +136,8 @@ class SMBFileSystemProvider(LocalFileSystemProvider):
             await makedirs(self.base_path)
 
         try:
-            await self.unmount()
+            # do unmount first to cleanup any unexpected state
+            await self.unmount(ignore_error=True)
             await self.mount()
         except Exception as err:
             raise LoginFailed(f"Connection failed for the given details: {err}") from err
@@ -193,7 +194,7 @@ class SMBFileSystemProvider(LocalFileSystemProvider):
         if proc.returncode != 0:
             raise LoginFailed(f"SMB mount failed with error: {stderr.decode()}")
 
-    async def unmount(self) -> None:
+    async def unmount(self, ignore_error: bool = False) -> None:
         """Unmount the remote share."""
         proc = await asyncio.create_subprocess_shell(
             f"umount {self.base_path}",
@@ -201,5 +202,5 @@ class SMBFileSystemProvider(LocalFileSystemProvider):
             stderr=asyncio.subprocess.PIPE,
         )
         _, stderr = await proc.communicate()
-        if proc.returncode != 0:
+        if proc.returncode != 0 and not ignore_error:
             self.logger.warning("SMB unmount failed with error: %s", stderr.decode())
index eedc7cda593f5e2af376cdc3551a11db55338a56..a5242a0e596d1b8bc82ce8fedb913274554bb769 100644 (file)
@@ -326,9 +326,9 @@ class PlexProvider(MusicProvider):
 
         album.artists.append(
             self._get_item_mapping(
-                media_type=MediaType.ARTIST,
-                url=plex_album.parentKey,
-                provider=plex_album.parentTitle,
+                MediaType.ARTIST,
+                plex_album.parentKey,
+                plex_album.parentTitle,
             )
         )
         return album
index d164d1e08a633c7b573460a86fe51df87f4fd1b5..687d20c7e15e4115fde67cb8b2200ff9cb72b223 100644 (file)
@@ -254,7 +254,8 @@ class SonosPlayerProvider(PlayerProvider):
             for player_id in list(self.sonosplayers):
                 player = self.sonosplayers.pop(player_id)
                 player.player.available = False
-                player.soco.end_direct_control_session()
+                if player.soco.is_coordinator:
+                    player.soco.end_direct_control_session()
         self.sonosplayers = None
 
     def on_player_config_changed(
@@ -620,9 +621,10 @@ class SonosPlayerProvider(PlayerProvider):
             self.mass.players.update(sonos_player.player_id)
 
         # enqueue next item if needed
-        if sonos_player.player.state == PlayerState.PLAYING and (
-            sonos_player.next_url is None
-            or sonos_player.next_url == sonos_player.player.current_url
+        if (
+            sonos_player.player.state == PlayerState.PLAYING
+            and sonos_player.player.active_source == sonos_player.player.player_id
+            and sonos_player.next_url in (None, sonos_player.player.current_url)
         ):
             self.mass.create_task(self._enqueue_next_track(sonos_player))
 
index f2385e0da6b076595ab3bf018ebc2c0339dc8326..6b3f667e98bd58cecbe53857c6f1449d8b66d854 100644 (file)
@@ -479,8 +479,7 @@ class YoutubeMusicProvider(MusicProvider):
                     if track:
                         tracks.append(track)
                 except InvalidDataError:
-                    track = await self.get_track(track["videoId"])
-                    if track:
+                    if track := await self.get_track(track["videoId"]):
                         tracks.append(track)
             return tracks
         return []
@@ -493,6 +492,8 @@ class YoutubeMusicProvider(MusicProvider):
             headers=self._headers,
             signature_timestamp=self._signature_timestamp,
         )
+        if not track_obj:
+            raise MediaNotFoundError(f"Item {item_id} not found")
         stream_format = await self._parse_stream_format(track_obj)
         url = await self._parse_stream_url(stream_format=stream_format, item_id=item_id)
         if not await self._is_valid_deciphered_url(url=url):
index e994ad0f25f08ae65f1972df7f3316dfce50d190..f4203427f37cb8ce88fa6c61c31970a87fd8f44e 100644 (file)
@@ -65,13 +65,16 @@ async def get_playlist(prov_playlist_id: str, headers: dict[str, str]) -> dict[s
 
 async def get_track(
     prov_track_id: str, headers: dict[str, str], signature_timestamp: str
-) -> dict[str, str]:
+) -> dict[str, str] | None:
     """Async wrapper around the ytmusicapi get_playlist function."""
 
     def _get_song():
         ytm = ytmusicapi.YTMusic(auth=json.dumps(headers))
         track_obj = ytm.get_song(videoId=prov_track_id, signatureTimestamp=signature_timestamp)
         track = {}
+        if "videoDetails" not in track_obj:
+            # video that no longer exists
+            return None
         track["videoId"] = track_obj["videoDetails"]["videoId"]
         track["title"] = track_obj["videoDetails"]["title"]
         track["artists"] = [