Various bug fixes and small improvements (#614)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 4 Apr 2023 19:03:49 +0000 (21:03 +0200)
committerGitHub <noreply@github.com>
Tue, 4 Apr 2023 19:03:49 +0000 (21:03 +0200)
* disable dev mode for now

* ensure int for db id

* code style

* ensure int for db item id

* guard missing videoId in YTM

* typo in prebuffer

* change max prebuffer to 30 seconds

* fix icy metadata

* image is not working so leave it out of icy meta

* some fixes for flow mode streaming

* change buffer logic

* change to 10 seconds

* make queueitems a generator

* rename active_queue to active_source

* fix player end of queue

* handle active source vs active queue

* fix hiding of child players

* fix add to queue select default item

* bump frontend

* 2.0.0b25

20 files changed:
.vscode/launch.json
music_assistant/common/models/player.py
music_assistant/common/models/queue_item.py
music_assistant/constants.py
music_assistant/server/controllers/config.py
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/playlists.py
music_assistant/server/controllers/media/radio.py
music_assistant/server/controllers/media/tracks.py
music_assistant/server/controllers/player_queues.py
music_assistant/server/controllers/players.py
music_assistant/server/controllers/streams.py
music_assistant/server/models/music_provider.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/chromecast/helpers.py
music_assistant/server/providers/ytmusic/__init__.py
pyproject.toml
requirements_all.txt

index 9d78b2c114e0ab9d7ed39942c5deba89b503c0eb..5a280cfdeb2523a603cf38d2f16d4edd36432adb 100644 (file)
@@ -13,7 +13,7 @@
             "args":[
                  "--log-level", "debug"
             ],
-            "env": {"PYTHONDEVMODE": "1"}
+            // "env": {"PYTHONDEVMODE": "1"}
         }
     ]
 }
index 57c9accc11bfc5f9d8f8ee883775905fb8b16a43..c23033a4a44487ff7747659b70cd60187da0c773 100644 (file)
@@ -47,10 +47,10 @@ class Player(DataClassDictMixin):
     #   this will return the id's of players synced to this player.
     group_childs: list[str] = field(default_factory=list)
 
-    # active_queue: return player_id of the active queue for this player
+    # active_source: return player_id of the active queue for this player
     # if the player is grouped and a group is active, this will be set to the group's player_id
     # otherwise it will be set to the own player_id
-    active_queue: str = ""
+    active_source: str = ""
 
     # can_sync_with: return tuple of player_ids that can be synced to/with this player
     # usually this is just a list of all player_ids within the playerprovider
index 3b395f0e2ef86c75fa1ffdf13816b7636ee1b880..a3338c7c15c4bc7f2c2d29a39696374de4963de6 100644 (file)
@@ -27,6 +27,7 @@ class QueueItem(DataClassDictMixin):
     streamdetails: StreamDetails | None = None
     media_item: Track | Radio | None = None
     image: MediaItemImage | None = None
+    index: int = 0
 
     def __post_init__(self):
         """Set default values."""
index 6c5a3ecf8ccb7b55a691ea5be32286ef892f6a01..ad4be3c2ee5a33c7752cf04c4fb026b250582058 100755 (executable)
@@ -3,7 +3,7 @@
 import pathlib
 from typing import Final
 
-__version__: Final[str] = "2.0.0b24"
+__version__: Final[str] = "2.0.0b25"
 
 SCHEMA_VERSION: Final[int] = 22
 
index e49a6da1763a8b907f2a21aa03405fe5d9455ba5..1f386cdb560a517bb3c7a119271a81bdeddf31fa 100644 (file)
@@ -294,7 +294,7 @@ class ConfigController:
         try:
             player = self.mass.players.get(config.player_id)
             player.enabled = config.enabled
-            self.mass.players.update(config.player_id)
+            self.mass.players.update(config.player_id, force_update=True)
         except PlayerUnavailableError:
             pass
 
index f9a76170a73fee0fa2015d4402ce336cc3ce190f..3bb2448e8e567ffa70dd268b9381b39e151b5c94 100644 (file)
@@ -122,15 +122,17 @@ class AlbumsController(MediaControllerBase[Album]):
         )
         return db_item
 
-    async def update(self, item_id: int, update: Album, overwrite: bool = False) -> Album:
+    async def update(self, item_id: str | int, update: Album, overwrite: bool = False) -> Album:
         """Update existing record in the database."""
-        return await self._update_db_item(item_id=item_id, item=update, overwrite=overwrite)
+        db_id = int(item_id)  # ensure integer
+        return await self._update_db_item(item_id=db_id, item=update, overwrite=overwrite)
 
-    async def delete(self, item_id: int, recursive: bool = False) -> None:
+    async def delete(self, item_id: str | int, recursive: bool = False) -> None:
         """Delete record from the database."""
+        db_id = int(item_id)  # ensure integer
         # check album tracks
         db_rows = await self.mass.music.database.get_rows_from_query(
-            f"SELECT item_id FROM {DB_TABLE_TRACKS} WHERE albums LIKE '%\"{item_id}\"%'",
+            f"SELECT item_id FROM {DB_TABLE_TRACKS} WHERE albums LIKE '%\"{db_id}\"%'",
             limit=5000,
         )
         assert not (db_rows and not recursive), "Tracks attached to album"
@@ -243,10 +245,11 @@ class AlbumsController(MediaControllerBase[Album]):
         return await self.get_db_item(item_id)
 
     async def _update_db_item(
-        self, item_id: int, item: Album | ItemMapping, overwrite: bool = False
+        self, item_id: str | int, item: Album | ItemMapping, overwrite: bool = False
     ) -> Album:
         """Update Album record in the database."""
-        cur_item = await self.get_db_item(item_id)
+        db_id = int(item_id)  # ensure integer
+        cur_item = await self.get_db_item(db_id)
         metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite)
         provider_mappings = self._get_provider_mappings(cur_item, item, overwrite)
         album_artists = await self._get_artist_mappings(cur_item, item, overwrite)
@@ -260,7 +263,7 @@ class AlbumsController(MediaControllerBase[Album]):
         async with self._db_add_lock:
             await self.mass.music.database.update(
                 self.db_table,
-                {"item_id": item_id},
+                {"item_id": db_id},
                 {
                     "name": item.name if overwrite else cur_item.name,
                     "sort_name": item.sort_name if overwrite else cur_item.sort_name,
@@ -277,9 +280,9 @@ class AlbumsController(MediaControllerBase[Album]):
                 },
             )
             # update/set provider_mappings table
-            await self._set_provider_mappings(item_id, provider_mappings)
-            self.logger.debug("updated %s in database: %s", item.name, item_id)
-        return await self.get_db_item(item_id)
+            await self._set_provider_mappings(db_id, provider_mappings)
+            self.logger.debug("updated %s in database: %s", item.name, db_id)
+        return await self.get_db_item(db_id)
 
     async def _get_provider_album_tracks(
         self, item_id: str, provider_instance_id_or_domain: str
@@ -358,13 +361,14 @@ class AlbumsController(MediaControllerBase[Album]):
 
     async def _get_db_album_tracks(
         self,
-        item_id: str,
+        item_id: str | int,
     ) -> list[Track]:
         """Return in-database album tracks for the given database album."""
-        db_album = await self.get_db_item(item_id)
+        db_id = int(item_id)  # ensure integer
+        db_album = await self.get_db_item(db_id)
         # simply grab all tracks in the db that are linked to this album
         # TODO: adjust to json query instead of text search?
-        query = f'SELECT * FROM {DB_TABLE_TRACKS} WHERE albums LIKE \'%"item_id":"{item_id}","provider":"database"%\''  # noqa: E501
+        query = f'SELECT * FROM {DB_TABLE_TRACKS} WHERE albums LIKE \'%"item_id":"{db_id}","provider":"database"%\''  # noqa: E501
         result = []
         for track in await self.mass.music.tracks.get_db_items_by_query(query):
             if album_mapping := next(
index d88831e03f96587ad11dbac7f917547bed88b14a..cd0f25fb96c73500a52dfd2ad3eb2b8dd66bad83 100644 (file)
@@ -76,7 +76,7 @@ class ArtistsController(MediaControllerBase[Artist]):
         )
         return db_item
 
-    async def update(self, item_id: int, update: Artist, overwrite: bool = False) -> Artist:
+    async def update(self, item_id: str | int, update: Artist, overwrite: bool = False) -> Artist:
         """Update existing record in the database."""
         return await self._update_db_item(item_id=item_id, item=update, overwrite=overwrite)
 
@@ -158,11 +158,12 @@ class ArtistsController(MediaControllerBase[Artist]):
                 final_items[key].in_library = True
         return list(final_items.values())
 
-    async def delete(self, item_id: int, recursive: bool = False) -> None:
+    async def delete(self, item_id: str | int, recursive: bool = False) -> None:
         """Delete record from the database."""
+        db_id = int(item_id)  # ensure integer
         # check artist albums
         db_rows = await self.mass.music.database.get_rows_from_query(
-            f"SELECT item_id FROM {DB_TABLE_ALBUMS} WHERE artists LIKE '%\"{item_id}\"%'",
+            f"SELECT item_id FROM {DB_TABLE_ALBUMS} WHERE artists LIKE '%\"{db_id}\"%'",
             limit=5000,
         )
         assert not (db_rows and not recursive), "Albums attached to artist"
@@ -172,7 +173,7 @@ class ArtistsController(MediaControllerBase[Artist]):
 
         # check artist tracks
         db_rows = await self.mass.music.database.get_rows_from_query(
-            f"SELECT item_id FROM {DB_TABLE_TRACKS} WHERE artists LIKE '%\"{item_id}\"%'",
+            f"SELECT item_id FROM {DB_TABLE_TRACKS} WHERE artists LIKE '%\"{db_id}\"%'",
             limit=5000,
         )
         assert not (db_rows and not recursive), "Tracks attached to artist"
@@ -181,7 +182,7 @@ class ArtistsController(MediaControllerBase[Artist]):
                 await self.mass.music.albums.delete(db_row["item_id"], recursive)
 
         # delete the artist itself from db
-        await super().delete(item_id)
+        await super().delete(db_id)
 
     async def match_artist(self, db_artist: Artist):
         """Try to find matching artists on all providers for the provided (database) item_id.
@@ -322,10 +323,11 @@ class ArtistsController(MediaControllerBase[Artist]):
         return await self.get_db_item(item_id)
 
     async def _update_db_item(
-        self, item_id: int, item: Artist | ItemMapping, overwrite: bool = False
+        self, item_id: str | int, item: Artist | ItemMapping, overwrite: bool = False
     ) -> Artist:
         """Update Artist record in the database."""
-        cur_item = await self.get_db_item(item_id)
+        db_id = int(item_id)  # ensure integer
+        cur_item = await self.get_db_item(db_id)
         metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite)
         provider_mappings = self._get_provider_mappings(cur_item, item, overwrite)
 
@@ -339,7 +341,7 @@ class ArtistsController(MediaControllerBase[Artist]):
         async with self._db_add_lock:
             await self.mass.music.database.update(
                 self.db_table,
-                {"item_id": item_id},
+                {"item_id": db_id},
                 {
                     "name": item.name if overwrite else cur_item.name,
                     "sort_name": item.sort_name if overwrite else cur_item.sort_name,
@@ -350,9 +352,9 @@ class ArtistsController(MediaControllerBase[Artist]):
                 },
             )
             # update/set provider_mappings table
-            await self._set_provider_mappings(item_id, provider_mappings)
-            self.logger.debug("updated %s in database: %s", item.name, item_id)
-        return await self.get_db_item(item_id)
+            await self._set_provider_mappings(db_id, provider_mappings)
+            self.logger.debug("updated %s in database: %s", item.name, db_id)
+        return await self.get_db_item(db_id)
 
     async def _get_provider_dynamic_tracks(
         self,
index 4fae5df029bc587b6dd66366e55f1ec122aff149..a2aea7b9fb95effe8a9b5089e1964cb32ecd8df2 100644 (file)
@@ -49,27 +49,28 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         raise NotImplementedError
 
     @abstractmethod
-    async def update(self, item_id: int, update: ItemCls, overwrite: bool = False) -> ItemCls:
+    async def update(self, item_id: str | int, update: ItemCls, overwrite: bool = False) -> ItemCls:
         """Update existing record in the database."""
 
-    async def delete(self, item_id: int, recursive: bool = False) -> None:  # noqa: ARG002
+    async def delete(self, item_id: str | int, recursive: bool = False) -> None:  # noqa: ARG002
         """Delete record from the database."""
-        db_item = await self.get_db_item(item_id)
-        assert db_item, f"Item does not exist: {item_id}"
+        db_id = int(item_id)  # ensure integer
+        db_item = await self.get_db_item(db_id)
+        assert db_item, f"Item does not exist: {db_id}"
         # delete item
         await self.mass.music.database.delete(
             self.db_table,
-            {"item_id": int(item_id)},
+            {"item_id": db_id},
         )
         # update provider_mappings table
         await self.mass.music.database.delete(
             DB_TABLE_PROVIDER_MAPPINGS,
-            {"media_type": self.media_type.value, "item_id": int(item_id)},
+            {"media_type": self.media_type.value, "item_id": db_id},
         )
         # NOTE: this does not delete any references to this item in other records,
         # this is handled/overridden in the mediatype specific controllers
         self.mass.signal_event(EventType.MEDIA_ITEM_DELETED, db_item.uri, db_item)
-        self.logger.debug("deleted item with id %s from database", item_id)
+        self.logger.debug("deleted item with id %s from database", db_id)
 
     async def db_items(
         self,
@@ -316,10 +317,11 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
 
     async def get_db_item(self, item_id: int | str) -> ItemCls:
         """Get record by id."""
-        match = {"item_id": int(item_id)}
+        db_id = int(item_id)  # ensure integer
+        match = {"item_id": db_id}
         if db_row := await self.mass.music.database.get_row(self.db_table, match):
             return self.item_cls.from_db_row(db_row)
-        raise MediaNotFoundError(f"Album not found in database: {item_id}")
+        raise MediaNotFoundError(f"Album not found in database: {db_id}")
 
     async def get_db_item_by_prov_id(
         self,
@@ -386,11 +388,12 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
                 break
             offset += limit
 
-    async def set_db_library(self, item_id: int, in_library: bool) -> None:
+    async def set_db_library(self, item_id: str | int, in_library: bool) -> None:
         """Set the in-library bool on a database item."""
-        match = {"item_id": item_id}
+        db_id = int(item_id)  # ensure integer
+        match = {"item_id": db_id}
         await self.mass.music.database.update(self.db_table, match, {"in_library": in_library})
-        db_item = await self.get_db_item(item_id)
+        db_item = await self.get_db_item(db_id)
         self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item)
 
     async def get_provider_item(
@@ -428,10 +431,11 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
             "found on provider {provider_instance_id_or_domain}"
         )
 
-    async def remove_prov_mapping(self, item_id: int, provider_instance_id: str) -> None:
+    async def remove_prov_mapping(self, item_id: str | int, provider_instance_id: str) -> None:
         """Remove provider id(s) from item."""
+        db_id = int(item_id)  # ensure integer
         try:
-            db_item = await self.get_db_item(item_id)
+            db_item = await self.get_db_item(db_id)
         except MediaNotFoundError:
             # edge case: already deleted / race condition
             return
@@ -441,7 +445,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
             DB_TABLE_PROVIDER_MAPPINGS,
             {
                 "media_type": self.media_type.value,
-                "item_id": int(item_id),
+                "item_id": db_id,
                 "provider_instance": provider_instance_id,
             },
         )
@@ -450,19 +454,19 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         db_item.provider_mappings = {
             x for x in db_item.provider_mappings if x.provider_instance != provider_instance_id
         }
-        match = {"item_id": item_id}
+        match = {"item_id": db_id}
         if db_item.provider_mappings:
             await self.mass.music.database.update(
                 self.db_table,
                 match,
                 {"provider_mappings": serialize_to_json(db_item.provider_mappings)},
             )
-            self.logger.debug("removed provider %s from item id %s", provider_instance_id, item_id)
+            self.logger.debug("removed provider %s from item id %s", provider_instance_id, db_id)
             self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item)
         else:
             # delete item if it has no more providers
             with suppress(AssertionError):
-                await self.delete(item_id)
+                await self.delete(db_id)
 
     async def dynamic_tracks(
         self,
@@ -500,13 +504,14 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         """Get dynamic list of tracks for given item, fallback/default implementation."""
 
     async def _set_provider_mappings(
-        self, item_id: int, provider_mappings: list[ProviderMapping]
+        self, item_id: str | int, provider_mappings: list[ProviderMapping]
     ) -> None:
         """Update the provider_items table for the media item."""
+        db_id = int(item_id)  # ensure integer
         # clear all records first
         await self.mass.music.database.delete(
             DB_TABLE_PROVIDER_MAPPINGS,
-            {"media_type": self.media_type.value, "item_id": int(item_id)},
+            {"media_type": self.media_type.value, "item_id": db_id},
         )
         # add entries
         for provider_mapping in provider_mappings:
@@ -514,7 +519,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
                 DB_TABLE_PROVIDER_MAPPINGS,
                 {
                     "media_type": self.media_type.value,
-                    "item_id": item_id,
+                    "item_id": db_id,
                     "provider_domain": provider_mapping.provider_domain,
                     "provider_instance": provider_mapping.provider_instance,
                     "provider_item_id": provider_mapping.item_id,
index 007aded01ad3c7e8d4bfa643f5cb811cb1e8d575..e1bee2034901113ac8c846bc83433b3dd8c7a5f2 100644 (file)
@@ -98,22 +98,24 @@ class PlaylistController(MediaControllerBase[Playlist]):
 
         return await provider.create_playlist(name)
 
-    async def add_playlist_tracks(self, db_playlist_id: str, uris: list[str]) -> None:
+    async def add_playlist_tracks(self, db_playlist_id: str | int, uris: list[str]) -> None:
         """Add multiple tracks to playlist. Creates background tasks to process the action."""
-        playlist = await self.get_db_item(db_playlist_id)
+        db_id = int(db_playlist_id)  # ensure integer
+        playlist = await self.get_db_item(db_id)
         if not playlist:
-            raise MediaNotFoundError(f"Playlist with id {db_playlist_id} not found")
+            raise MediaNotFoundError(f"Playlist with id {db_id} not found")
         if not playlist.is_editable:
             raise InvalidDataError(f"Playlist {playlist.name} is not editable")
         for uri in uris:
-            self.mass.create_task(self.add_playlist_track(db_playlist_id, uri))
+            self.mass.create_task(self.add_playlist_track(db_id, uri))
 
-    async def add_playlist_track(self, db_playlist_id: str, track_uri: str) -> None:
+    async def add_playlist_track(self, db_playlist_id: str | int, track_uri: str) -> None:
         """Add track to playlist - make sure we dont add duplicates."""
+        db_id = int(db_playlist_id)  # ensure integer
         # we can only edit playlists that are in the database (marked as editable)
-        playlist = await self.get_db_item(db_playlist_id)
+        playlist = await self.get_db_item(db_id)
         if not playlist:
-            raise MediaNotFoundError(f"Playlist with id {db_playlist_id} not found")
+            raise MediaNotFoundError(f"Playlist with id {db_id} not found")
         if not playlist.is_editable:
             raise InvalidDataError(f"Playlist {playlist.name} is not editable")
         # make sure we have recent full track details
@@ -167,15 +169,16 @@ class PlaylistController(MediaControllerBase[Playlist]):
         provider = self.mass.get_provider(playlist_prov.provider_instance)
         await provider.add_playlist_tracks(playlist_prov.item_id, [track_id_to_add])
         # invalidate cache by updating the checksum
-        await self.get(db_playlist_id, "database", force_refresh=True)
+        await self.get(db_id, "database", force_refresh=True)
 
     async def remove_playlist_tracks(
-        self, db_playlist_id: str, positions_to_remove: tuple[int, ...]
+        self, db_playlist_id: str | int, positions_to_remove: tuple[int, ...]
     ) -> None:
         """Remove multiple tracks from playlist."""
-        playlist = await self.get_db_item(db_playlist_id)
+        db_id = int(db_playlist_id)  # ensure integer
+        playlist = await self.get_db_item(db_id)
         if not playlist:
-            raise MediaNotFoundError(f"Playlist with id {db_playlist_id} not found")
+            raise MediaNotFoundError(f"Playlist with id {db_id} not found")
         if not playlist.is_editable:
             raise InvalidDataError(f"Playlist {playlist.name} is not editable")
         for prov_mapping in playlist.provider_mappings:
@@ -188,7 +191,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
                 continue
             await provider.remove_playlist_tracks(prov_mapping.item_id, positions_to_remove)
         # invalidate cache by updating the checksum
-        await self.get(db_playlist_id, "database", force_refresh=True)
+        await self.get(db_id, "database", force_refresh=True)
 
     async def _add_db_item(self, item: Playlist) -> Playlist:
         """Add a new record to the database."""
@@ -210,16 +213,17 @@ class PlaylistController(MediaControllerBase[Playlist]):
         return await self.get_db_item(item_id)
 
     async def _update_db_item(
-        self, item_id: int, item: Playlist, overwrite: bool = False
+        self, item_id: str | int, item: Playlist, overwrite: bool = False
     ) -> Playlist:
         """Update Playlist record in the database."""
-        cur_item = await self.get_db_item(item_id)
+        db_id = int(item_id)  # ensure integer
+        cur_item = await self.get_db_item(db_id)
         metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite)
         provider_mappings = self._get_provider_mappings(cur_item, item, overwrite)
         async with self._db_add_lock:
             await self.mass.music.database.update(
                 self.db_table,
-                {"item_id": item_id},
+                {"item_id": db_id},
                 {
                     # always prefer name/owner from updated item here
                     "name": item.name or cur_item.name,
@@ -232,9 +236,9 @@ class PlaylistController(MediaControllerBase[Playlist]):
                 },
             )
             # update/set provider_mappings table
-            await self._set_provider_mappings(item_id, provider_mappings)
-            self.logger.debug("updated %s in database: %s", item.name, item_id)
-        return await self.get_db_item(item_id)
+            await self._set_provider_mappings(db_id, provider_mappings)
+            self.logger.debug("updated %s in database: %s", item.name, db_id)
+        return await self.get_db_item(db_id)
 
     async def _get_provider_playlist_tracks(
         self,
index 2779298b5b95fbfe82a6c1e5fdf7c5163b0deb08..e8a174f9bfbf6b9a24026afb425eaf861b38bb52 100644 (file)
@@ -72,7 +72,7 @@ class RadioController(MediaControllerBase[Radio]):
         )
         return db_item
 
-    async def update(self, item_id: int, update: Radio, overwrite: bool = False) -> Radio:
+    async def update(self, item_id: str | int, update: Radio, overwrite: bool = False) -> Radio:
         """Update existing record in the database."""
         return await self._update_db_item(item_id=item_id, item=update, overwrite=overwrite)
 
@@ -95,12 +95,15 @@ class RadioController(MediaControllerBase[Radio]):
         # return created object
         return await self.get_db_item(item_id)
 
-    async def _update_db_item(self, item_id: int, item: Radio, overwrite: bool = False) -> Radio:
+    async def _update_db_item(
+        self, item_id: str | int, item: Radio, overwrite: bool = False
+    ) -> Radio:
         """Update Radio record in the database."""
-        cur_item = await self.get_db_item(item_id)
+        db_id = int(item_id)  # ensure integer
+        cur_item = await self.get_db_item(db_id)
         metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite)
         provider_mappings = self._get_provider_mappings(cur_item, item, overwrite)
-        match = {"item_id": item_id}
+        match = {"item_id": db_id}
         async with self._db_add_lock:
             await self.mass.music.database.update(
                 self.db_table,
@@ -115,9 +118,9 @@ class RadioController(MediaControllerBase[Radio]):
                 },
             )
             # update/set provider_mappings table
-            await self._set_provider_mappings(item_id, provider_mappings)
-            self.logger.debug("updated %s in database: %s", item.name, item_id)
-        return await self.get_db_item(item_id)
+            await self._set_provider_mappings(db_id, provider_mappings)
+            self.logger.debug("updated %s in database: %s", item.name, db_id)
+        return await self.get_db_item(db_id)
 
     async def _get_provider_dynamic_tracks(
         self,
index 571f15fb725c8373ee90a608cc4ac5ec3a2282e4..f0f9236e16a70c38bb8837e088153fd119387dbc 100644 (file)
@@ -146,7 +146,7 @@ class TracksController(MediaControllerBase[Track]):
         )
         return db_item
 
-    async def update(self, item_id: int, update: Track, overwrite: bool = False) -> Track:
+    async def update(self, item_id: str | int, update: Track, overwrite: bool = False) -> Track:
         """Update existing record in the database."""
         return await self._update_db_item(item_id=item_id, item=update, overwrite=overwrite)
 
@@ -326,10 +326,11 @@ class TracksController(MediaControllerBase[Track]):
         return await self.get_db_item(item_id)
 
     async def _update_db_item(
-        self, item_id: int, item: Track | ItemMapping, overwrite: bool = False
+        self, item_id: str | int, item: Track | ItemMapping, overwrite: bool = False
     ) -> Track:
         """Update Track record in the database, merging data."""
-        cur_item = await self.get_db_item(item_id)
+        db_id = int(item_id)  # ensure integer
+        cur_item = await self.get_db_item(db_id)
         metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite)
         provider_mappings = self._get_provider_mappings(cur_item, item, overwrite)
         if getattr(item, "isrc", None):
@@ -339,7 +340,7 @@ class TracksController(MediaControllerBase[Track]):
         async with self._db_add_lock:
             await self.mass.music.database.update(
                 self.db_table,
-                {"item_id": item_id},
+                {"item_id": db_id},
                 {
                     "name": item.name or cur_item.name,
                     "sort_name": item.sort_name or cur_item.sort_name,
@@ -354,9 +355,9 @@ class TracksController(MediaControllerBase[Track]):
                 },
             )
             # update/set provider_mappings table
-            await self._set_provider_mappings(item_id, provider_mappings)
-            self.logger.debug("updated %s in database: %s", item.name, item_id)
-        return await self.get_db_item(item_id)
+            await self._set_provider_mappings(db_id, provider_mappings)
+            self.logger.debug("updated %s in database: %s", item.name, db_id)
+        return await self.get_db_item(db_id)
 
     async def _get_track_albums(
         self,
index e15b811124abb810811f3bd54e68ff3f01e1a717..37abe91261c782e49932863a89ef592077d1eebd 100755 (executable)
@@ -4,6 +4,7 @@ from __future__ import annotations
 import logging
 import random
 import time
+from collections.abc import AsyncGenerator
 from typing import TYPE_CHECKING
 
 from music_assistant.common.helpers.util import get_changed_keys
@@ -66,15 +67,20 @@ class PlayerQueuesController:
         return self._queues.get(queue_id)
 
     @api_command("players/queue/items")
-    def items(self, queue_id: str) -> list[QueueItem]:
+    async def items(self, queue_id: str) -> AsyncGenerator[QueueItem, None]:
         """Return all QueueItems for given PlayerQueue."""
-        return self._queue_items.get(queue_id, [])
+        # because the QueueItems can potentially be a very large list, this is a async generator
+        for index, queue_item in enumerate(self._queue_items.get(queue_id, [])):
+            queue_item.index = index
+            yield queue_item
 
     @api_command("players/queue/get_active_queue")
     def get_active_queue(self, player_id: str) -> PlayerQueue:
         """Return the current active/synced queue for a player."""
         player = self.mass.players.get(player_id)
-        return self.get(player.active_queue)
+        if queue := self.get(player.active_source):
+            return queue
+        return self.get(player_id)
 
     # Queue commands
 
@@ -125,7 +131,7 @@ class PlayerQueuesController:
         self.signal_update(queue_id)
 
     @api_command("players/queue/play_media")
-    async def play_media(  # noqa: PLR0915
+    async def play_media(
         self,
         queue_id: str,
         media: MediaItemType | list[MediaItemType] | str | list[str],
@@ -138,7 +144,7 @@ class PlayerQueuesController:
         - queue_opt: Which enqueue mode to use.
         - radio_mode: Enable radio mode for the given item(s).
         """
-        # ruff: noqa: PLR0915
+        # ruff: noqa: PLR0915,PLR0912
         queue = self._queues[queue_id]
         if queue.announcement_in_progress:
             LOGGER.warning("Ignore queue command: An announcement is in progress")
@@ -246,6 +252,12 @@ class PlayerQueuesController:
                 insert_at_index=insert_at_index,
                 shuffle=queue.shuffle_enabled,
             )
+            # handle edgecase, queue is empty and items are only added (not played)
+            # mark first item as new index
+            if queue.current_index is None:
+                queue.current_index = 0
+                queue.current_item = self.get_item(queue_id, 0)
+                self.signal_update(queue_id)
 
     @api_command("players/queue/move_item")
     def move_item(self, queue_id: str, queue_item_id: str, pos_shift: int = 1) -> None:
@@ -303,6 +315,8 @@ class PlayerQueuesController:
         if queue.state not in (PlayerState.IDLE, PlayerState.OFF):
             self.mass.create_task(self.stop(queue_id))
         queue.current_index = None
+        queue.current_item = None
+        queue.elapsed_time = 0
         queue.index_in_buffer = None
         self.update_items(queue_id, [])
 
@@ -503,76 +517,87 @@ class PlayerQueuesController:
     def on_player_update(self, player: Player, changed_keys: set[str]) -> None:
         """Call when a PlayerQueue needs to be updated (e.g. when player updates)."""
         if player.player_id not in self._queues:
-            self.mass.create_task(self.on_player_register(player))
+            # race condition
             return
         queue_id = player.player_id
         player = self.players.get(queue_id)
         queue = self._queues[queue_id]
 
-        # copy most properties from the player
+        # basic properties
         queue.display_name = player.display_name
         queue.available = player.available
         queue.items = len(self._queue_items[queue_id])
-        queue.state = player.state
-        queue.elapsed_time = int(player.corrected_elapsed_time)
-        queue.elapsed_time_last_updated = time.time()
-
         # determine if this queue is currently active for this player
-        queue.active = player.active_queue == queue.queue_id
+        queue.active = player.active_source == queue.queue_id
         if queue.active:
+            queue.state = player.state
             # update current item from player report
             player_item_index = self.index_by_id(queue_id, player.current_item_id)
             if player_item_index is None:
+                # try grabbing the item id from the url
                 player_item_index = self._get_player_item_index(queue_id, player.current_url)
-            if queue.flow_mode and player_item_index is not None:
-                # flow mode active, calculate current item
-                (
-                    queue.current_index,
-                    queue.elapsed_time,
-                ) = self.__get_queue_stream_index(queue, player, player_item_index)
-            else:
-                queue.current_index = player_item_index
-
-        queue.current_item = self.get_item(queue_id, queue.current_index)
-        queue.next_item = self.get_next_item(queue_id)
-
-        # correct elapsed time when seeking
-        if (
-            queue.current_item
-            and queue.current_item.streamdetails
-            and queue.current_item.streamdetails.seconds_skipped
-            and not queue.flow_mode
-        ):
-            queue.elapsed_time += queue.current_item.streamdetails.seconds_skipped
-
+            if player_item_index is not None:
+                if queue.flow_mode:
+                    # flow mode active, calculate current item
+                    current_index, item_time = self.__get_queue_stream_index(
+                        queue, player, player_item_index
+                    )
+                else:
+                    # queue is active and player has one of our tracks loaded, update state
+                    current_index = player_item_index
+                    item_time = int(player.corrected_elapsed_time)
+                # only update these attributes if the queue is active
+                # and has an item loaded so we are able to resume it
+                queue.current_index = current_index
+                queue.elapsed_time = item_time
+                queue.elapsed_time_last_updated = time.time()
+                queue.current_item = self.get_item(queue_id, queue.current_index)
+                queue.next_item = self.get_next_item(queue_id)
+                # correct elapsed time when seeking
+                if (
+                    queue.current_item
+                    and queue.current_item.streamdetails
+                    and queue.current_item.streamdetails.seconds_skipped
+                    and not queue.flow_mode
+                ):
+                    queue.elapsed_time += queue.current_item.streamdetails.seconds_skipped
+        else:
+            queue.state = PlayerState.IDLE
         # basic throttle: do not send state changed events if queue did not actually change
         prev_state = self._prev_states.get(queue_id, {})
-        new_state = self._queues[queue_id].to_dict()
+        new_state = queue.to_dict()
+        new_state.pop("elapsed_time_last_updated", None)
         changed_keys = get_changed_keys(prev_state, new_state)
-        self._prev_states[queue_id] = new_state
 
+        # return early if nothing changed
         if len(changed_keys) == 0:
             return
-
-        if "elapsed_time" in changed_keys:
+        # do not send full updates if only time was updated
+        if changed_keys == {"elapsed_time"}:
             self.mass.signal_event(
                 EventType.QUEUE_TIME_UPDATED,
                 object_id=queue_id,
                 data=queue.elapsed_time,
             )
-        # do not send full updates if only time was updated
-        if changed_keys in (
-            {"elapsed_time_last_updated"},
-            {
-                "elapsed_time",
-                "elapsed_time_last_updated",
-            },
-        ):
-            # ignore
+            self._prev_states[queue_id] = new_state
             return
-
-        # only signal queue updated event if other properties than elapsed_time updated
+        # handle player was playing and is now stopped
+        # if player finished playing a track for 90%, mark current item as finished
+        if (
+            prev_state.get("state") == "playing"
+            and queue.state == PlayerState.IDLE
+            and (
+                queue.current_item
+                and queue.current_item.duration
+                and queue.elapsed_time > (queue.current_item.duration * 0.8)
+            )
+        ):
+            queue.current_index += 1
+            queue.current_item = None
+            queue.next_item = None
+        # signal update and store state
         self.signal_update(queue_id)
+        self._prev_states[queue_id] = new_state
         # watch dynamic radio items refill if needed
         if "current_index" in changed_keys:
             fill_index = len(self._queue_items[queue_id]) - 5
@@ -587,7 +612,7 @@ class PlayerQueuesController:
         self._queue_items.pop(player_id, None)
 
     async def player_ready_for_next_track(
-        self, queue_or_player_id: str, current_item_id: str | None = None
+        self, queue_or_player_id: str, current_item_id: str
     ) -> tuple[QueueItem, bool]:
         """Call when a player is ready to load the next track into the buffer.
 
@@ -599,10 +624,7 @@ class PlayerQueuesController:
         just like with the play_media call.
         """
         queue = self.get_active_queue(queue_or_player_id)
-        if current_item_id is None:
-            cur_index = queue.current_index
-        else:
-            cur_index = self.index_by_id(queue.queue_id, current_item_id)
+        cur_index = self.index_by_id(queue.queue_id, current_item_id)
         cur_item = self.get_item(queue.queue_id, cur_index)
         next_index = self.get_next_index(queue.queue_id, cur_index)
         next_item = self.get_item(queue.queue_id, next_index)
index cdeb0a31401e11e6830d264c5a210bc904ccb5cb..d641b74fd3367310b32073951e6fb170d13e821c 100755 (executable)
@@ -21,7 +21,7 @@ from music_assistant.common.models.errors import (
     UnsupportedFeaturedException,
 )
 from music_assistant.common.models.player import Player
-from music_assistant.constants import CONF_PLAYERS, ROOT_LOGGER_NAME
+from music_assistant.constants import CONF_HIDE_GROUP_CHILDS, CONF_PLAYERS, ROOT_LOGGER_NAME
 from music_assistant.server.helpers.api import api_command
 from music_assistant.server.models.player_provider import PlayerProvider
 
@@ -162,13 +162,15 @@ class PlayerController:
         self.mass.signal_event(EventType.PLAYER_REMOVED, player_id)
 
     @api_command("players/update")
-    def update(self, player_id: str, skip_forward: bool = False) -> None:
+    def update(
+        self, player_id: str, skip_forward: bool = False, force_update: bool = False
+    ) -> None:
         """Update player state."""
         if player_id not in self._players:
             return
         player = self._players[player_id]
-        # calculate active_queue
-        player.active_queue = self._get_active_queue(player)
+        # calculate active_source
+        player.active_source = self._get_active_source(player)
         # calculate group volume
         player.group_volume = self._get_group_volume_level(player)
         # prefer any overridden name from config
@@ -182,6 +184,21 @@ class PlayerController:
             player.state = PlayerState.IDLE
         elif not player.powered:
             player.state = PlayerState.OFF
+        # handle automatic hiding of group child's feature
+        for group_player in self._get_player_groups(player_id):
+            try:
+                hide_group_childs = self.mass.config.get_player_config_value(
+                    group_player.player_id, CONF_HIDE_GROUP_CHILDS
+                ).value
+            except KeyError:
+                continue
+            if hide_group_childs == "always":
+                player.hidden_by.add(group_player.player_id)
+            elif group_player.powered:
+                if hide_group_childs == "active":
+                    player.hidden_by.add(group_player.player_id)
+            elif group_player.player_id in player.hidden_by:
+                player.hidden_by.remove(group_player.player_id)
         # basic throttle: do not send state changed events if player did not actually change
         prev_state = self._prev_states.get(player_id, {})
         new_state = self._players[player_id].to_dict()
@@ -192,14 +209,14 @@ class PlayerController:
         )
         self._prev_states[player_id] = new_state
 
-        if not player.enabled and "enabled" not in changed_keys:
+        if not player.enabled and not force_update:
             # ignore updates for disabled players
             return
 
         # always signal update to the playerqueue
         self.queues.on_player_update(player, changed_keys)
 
-        if len(changed_keys) == 0:
+        if len(changed_keys) == 0 and not force_update:
             return
 
         self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player)
@@ -211,11 +228,11 @@ class PlayerController:
             for child_player_id in player.group_childs:
                 if child_player_id == player_id:
                     continue
-                self.update(child_player_id, skip_forward=True)
+                self.update(child_player_id, skip_forward=True, force_update=force_update)
 
         # update group player(s) when child updates
         for group_player in self._get_player_groups(player_id):
-            self.update(group_player.player_id, skip_forward=True)
+            self.update(group_player.player_id, skip_forward=True, force_update=force_update)
 
     def get_player_provider(self, player_id: str) -> PlayerProvider:
         """Return PlayerProvider for given player."""
@@ -456,23 +473,33 @@ class PlayerController:
         """Return all (player_ids of) any groupplayers the given player belongs to."""
         return tuple(x for x in self if player_id in x.group_childs)
 
-    def _get_active_queue(self, player: Player) -> str:
-        """Return the active_queue id for given player."""
+    def _get_active_source(self, player: Player) -> str:
+        """Return the active_source id for given player."""
         # if player is synced, return master/group leader
         if player.synced_to and player.synced_to in self._players:
-            return self._get_active_queue(self.get(player.synced_to))
+            return self._get_active_source(self.get(player.synced_to))
         # iterate player groups to find out if one is playing
         if group_players := self._get_player_groups(player.player_id):
             # prefer the first playing (or paused) group parent
             for group_player in group_players:
                 if group_player.state in (PlayerState.PLAYING, PlayerState.PAUSED):
-                    return group_player.player_id
+                    return group_player.active_source
             # fallback to the first powered group player
             for group_player in group_players:
                 if group_player.powered:
-                    return group_player.player_id
+                    return group_player.active_source
         # defaults to the player's own player id
-        return player.player_id
+        if player.current_url:
+            if self.mass.webserver.base_url in player.current_url:
+                return player.player_id
+            elif ":" in player.current_url:
+                # extract source from uri/url
+                return player.current_url.split(":")[0]
+            return player.current_item_id or player.current_url
+        elif not player.powered:
+            # reset active source when player powers off
+            return player.player_id
+        return player.active_source
 
     def _get_group_volume_level(self, player: Player) -> int:
         """Calculate a group volume from the grouped members."""
@@ -526,7 +553,7 @@ class PlayerController:
                 # if the player is playing, update elapsed time every tick
                 # to ensure the queue has accurate details
                 player_playing = (
-                    player.active_queue == player.player_id and player.state == PlayerState.PLAYING
+                    player.active_source == player.player_id and player.state == PlayerState.PLAYING
                 )
                 if player_playing:
                     self.mass.loop.call_soon(self.update, player_id)
index 6b52d27a8ef0047667c2ed70032e7b14ed97db3a..1431a217c4e0aa05f4693e06a0467b94281e3bc3 100644 (file)
@@ -94,7 +94,7 @@ class StreamJob:
         self.start()
         self.seen_players.add(player_id)
         try:
-            sub_queue = asyncio.Queue(3)
+            sub_queue = asyncio.Queue(1)
 
             # some checks
             assert player_id not in self.subscribers, "No duplicate subscriptions allowed"
@@ -119,18 +119,25 @@ class StreamJob:
                     break
                 yield chunk
         finally:
-            # some delay here to detect misbehaving (reconnecting) players
-            await asyncio.sleep(2)
             empty_queue(sub_queue)
             self.subscribers.pop(player_id)
+            # some delay here to detect misbehaving (reconnecting) players
             await asyncio.sleep(2)
             # check if this was the last subscriber and we should cancel
             if len(self.subscribers) == 0 and self._audio_task and not self.finished:
                 self._audio_task.cancel()
 
-    async def _put_data(self, data: Any, timeout: float = 1200) -> None:
+    async def _put_data(self, data: Any, timeout: float = 120) -> None:
         """Put chunk of data to all subscribers."""
         async with asyncio.timeout(timeout):
+            while len(self.subscribers) == 0:
+                # this may happen with misbehaving clients that do
+                # multiple GET requests for the same audio stream.
+                # they receive the first chunk, disconnect and then
+                # directly reconnect again.
+                if not self._audio_task or self.finished:
+                    return
+                await asyncio.sleep(0.1)
             async with asyncio.TaskGroup() as tg:
                 for sub_id in self.subscribers:
                     sub_queue = self.subscribers[sub_id]
@@ -357,7 +364,7 @@ class StreamsController:
         if request.method == "HEAD":
             return resp
 
-        # handler workaround for players that do 2 multiple GET requests
+        # handle workaround for players that do 2 multiple GET requests
         # for the same audio stream (because of the missing duration/length)
         if player_id in self.workaround_players and player_id not in stream_job.seen_players:
             stream_job.seen_players.add(player_id)
@@ -397,12 +404,14 @@ class StreamsController:
         async with AsyncProcess(ffmpeg_args, True) as ffmpeg_proc:
             # feed stdin with pcm audio chunks from origin
             async def read_audio():
-                async for chunk in stream_job.subscribe(player_id):
-                    try:
-                        await ffmpeg_proc.write(chunk)
-                    except BrokenPipeError:
-                        break
-                ffmpeg_proc.write_eof()
+                try:
+                    async for chunk in stream_job.subscribe(player_id):
+                        try:
+                            await ffmpeg_proc.write(chunk)
+                        except BrokenPipeError:
+                            break
+                finally:
+                    ffmpeg_proc.write_eof()
 
             ffmpeg_proc.attach_task(read_audio())
 
@@ -412,38 +421,28 @@ class StreamsController:
                 if enable_icy
                 else ffmpeg_proc.iter_chunked(128000)
             )
-
-            bytes_streamed = 0
-
             async for chunk in iterator:
                 try:
                     await resp.write(chunk)
                 except (BrokenPipeError, ConnectionResetError):
                     # race condition
                     break
-                bytes_streamed += len(chunk)
-
-                # do not allow the player to prebuffer more than 60 seconds
-                seconds_streamed = int(bytes_streamed / stream_job.pcm_sample_size)
-                if (
-                    seconds_streamed > 120
-                    and (seconds_streamed - player.corrected_elapsed_time) > 30
-                ):
-                    await asyncio.sleep(1)
 
                 if not enable_icy:
                     continue
 
                 # if icy metadata is enabled, send the icy metadata after the chunk
+                current_item = self.mass.players.queues.get_item(
+                    queue.queue_id, queue.index_in_buffer
+                )
                 if (
-                    queue
-                    and queue.current_item
-                    and queue.current_item.streamdetails
-                    and queue.current_item.streamdetails.stream_title
+                    current_item
+                    and current_item.streamdetails
+                    and current_item.streamdetails.stream_title
                 ):
-                    title = queue.current_item.streamdetails.stream_title
-                elif queue.current_item and queue.current_item.name:
-                    title = queue.current_item.name
+                    title = current_item.streamdetails.stream_title
+                elif queue and current_item and current_item.name:
+                    title = current_item.name
                 else:
                     title = "Music Assistant"
                 metadata = f"StreamTitle='{title}';".encode()
@@ -465,6 +464,7 @@ class StreamsController:
         # ruff: noqa: PLR0915
         queue_id = stream_job.queue_item.queue_id
         queue = self.mass.players.queues.get(queue_id)
+        queue_player = self.mass.players.get(queue_id)
         queue_track = None
         last_fadeout_part = b""
 
@@ -535,6 +535,15 @@ class StreamsController:
             ):
                 chunk_num += 1
 
+                # slow down if the player buffers too aggressively
+                seconds_streamed = int(bytes_written / stream_job.pcm_sample_size)
+                if (
+                    seconds_streamed > 10
+                    and queue_player.corrected_elapsed_time > 10
+                    and (seconds_streamed - queue_player.corrected_elapsed_time) > 10
+                ):
+                    await asyncio.sleep(1)
+
                 ####  HANDLE FIRST PART OF TRACK
 
                 # buffer full for crossfade
index bcbcee69f80e83faef716f0510adbb2aedc44a38..5056921e8dfbe559e7443cab7c9e2160b700a63c 100644 (file)
@@ -399,19 +399,20 @@ class MusicProvider(Provider):
             controller = self.mass.music.get_controller(media_type)
             cur_db_ids = set()
             async for prov_item in self._get_library_gen(media_type):
-                db_item: MediaItemType = await controller.get_db_item_by_prov_id(
-                    prov_item.item_id,
-                    prov_item.provider,
-                )
-                if not db_item:  # noqa: SIM114
+                db_item: MediaItemType
+                if not (
+                    db_item := await controller.get_db_item_by_prov_id(
+                        prov_item.item_id,
+                        prov_item.provider,
+                    )
+                ):
                     # create full db item
                     db_item = await controller.add(prov_item, skip_metadata_lookup=True)
-
                 elif (
                     db_item.metadata.checksum and prov_item.metadata.checksum
                 ) and db_item.metadata.checksum != prov_item.metadata.checksum:
-                    # item checksum changed
-                    db_item = await controller.add(prov_item, skip_metadata_lookup=True)
+                    # existing dbitem checksum changed
+                    db_item = await controller.update(db_item.item_id, prov_item)
                 cur_db_ids.add(db_item.item_id)
                 if not db_item.in_library:
                     await controller.set_db_library(db_item.item_id, True)
index e54ead92113262fb3ee76875ca3cb89d31813198..cdffbf1dac6455341ae41588cc61366ef6534d19 100644 (file)
@@ -50,7 +50,7 @@ if TYPE_CHECKING:
     from pychromecast.controllers.receiver import CastStatus
     from pychromecast.socket_client import ConnectionStatus
 
-    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.config_entries import PlayerConfig, ProviderConfig
     from music_assistant.common.models.provider import ProviderManifest
     from music_assistant.server import MusicAssistant
     from music_assistant.server.models import ProviderInstanceType
@@ -180,6 +180,13 @@ class ChromecastProvider(PlayerProvider):
             )
         return entries
 
+    def on_player_config_changed(
+        self, config: PlayerConfig, changed_keys: set[str]  # noqa: ARG002
+    ) -> None:
+        """Call (by config manager) when the configuration of a player changes."""
+        if "enabled" in changed_keys and config.player_id not in self.castplayers:
+            self.mass.create_task(self.mass.config.reload_provider, self.instance_id)
+
     async def cmd_stop(self, player_id: str) -> None:
         """Send STOP command to given player."""
         castplayer = self.castplayers[player_id]
@@ -369,7 +376,7 @@ class ChromecastProvider(PlayerProvider):
             self.castplayers[player_id] = castplayer
 
             castplayer.status_listener = CastStatusListener(self, castplayer, self.mz_mgr)
-            if cast_info.is_audio_group:
+            if cast_info.is_audio_group and not cast_info.is_multichannel_group:
                 mz_controller = MultizoneController(cast_info.uuid)
                 castplayer.cc.register_handler(mz_controller)
                 castplayer.mz_controller = mz_controller
@@ -397,6 +404,8 @@ class ChromecastProvider(PlayerProvider):
             status.volume_level,
         )
         castplayer.player.name = castplayer.cast_info.friendly_name
+        castplayer.player.volume_level = int(status.volume_level * 100)
+        castplayer.player.volume_muted = status.volume_muted
         if castplayer.active_group:
             # use mute as power when group is active
             castplayer.player.powered = not status.volume_muted
@@ -405,15 +414,12 @@ class ChromecastProvider(PlayerProvider):
                 castplayer.cc.app_id is not None
                 and castplayer.cc.app_id != pychromecast.IDLE_APP_ID
             )
-        castplayer.player.volume_level = int(status.volume_level * 100)
-        castplayer.player.volume_muted = status.volume_muted
-
         # handle stereo pairs
         if castplayer.cast_info.is_multichannel_group:
             castplayer.player.type = PlayerType.STEREO_PAIR
             castplayer.player.group_childs = []
         # handle cast groups
-        elif castplayer.cast_info.is_audio_group:
+        if castplayer.cast_info.is_audio_group and not castplayer.cast_info.is_multichannel_group:
             castplayer.player.type = PlayerType.GROUP
             castplayer.player.group_childs = [
                 str(UUID(x)) for x in castplayer.mz_controller.members
@@ -422,6 +428,7 @@ class ChromecastProvider(PlayerProvider):
                 PlayerFeature.POWER,
                 PlayerFeature.VOLUME_SET,
             )
+
         # send update to player manager
         self.mass.loop.call_soon_threadsafe(self.mass.players.update, castplayer.player_id)
 
index 998d21b16a850eb7d5170ab3ccd3c7dd2abf93a4..483602f962c83e9aab07539bad97e5f8dad58bde 100644 (file)
@@ -10,8 +10,6 @@ from pychromecast import dial
 from pychromecast.const import CAST_TYPE_GROUP
 from zeroconf import ServiceInfo
 
-from music_assistant.constants import CONF_HIDE_GROUP_CHILDS
-
 if TYPE_CHECKING:
     from pychromecast.controllers.media import MediaStatus
     from pychromecast.controllers.multizone import MultizoneManager
@@ -172,12 +170,7 @@ class CastStatusListener:
         self.prov.logger.debug(
             "%s is added to multizone: %s", self.castplayer.player.display_name, group_uuid
         )
-        if group_player := self.prov.castplayers.get(group_uuid):
-            hide_group_childs = self.prov.mass.config.get_player_config_value(
-                group_player.player_id, CONF_HIDE_GROUP_CHILDS
-            ).value
-            if hide_group_childs == "always":
-                self.castplayer.player.hidden_by.add(group_uuid)
+        self.new_cast_status(self.castplayer.cc.status)
 
     def removed_from_multizone(self, group_uuid):
         """Handle the cast removed from a group."""
@@ -188,26 +181,21 @@ class CastStatusListener:
         self.prov.logger.debug(
             "%s is removed from multizone: %s", self.castplayer.player.display_name, group_uuid
         )
+        self.new_cast_status(self.castplayer.cc.status)
 
     def multizone_new_cast_status(self, group_uuid, cast_status):  # noqa: ARG002
         """Handle reception of a new CastStatus for a group."""
         if group_player := self.prov.castplayers.get(group_uuid):
-            hide_group_childs = self.prov.mass.config.get_player_config_value(
-                group_uuid, CONF_HIDE_GROUP_CHILDS
-            ).value
-            if hide_group_childs == "always":
-                self.castplayer.player.hidden_by.add(group_uuid)
             if group_player.cc.media_controller.is_active:
                 self.castplayer.active_group = group_uuid
-                if hide_group_childs == "active":
-                    self.castplayer.player.hidden_by.add(group_uuid)
+                self.castplayer.player.active_source = group_uuid
             elif group_uuid == self.castplayer.active_group:
                 self.castplayer.active_group = None
-                if hide_group_childs != "always" and group_uuid in self.castplayer.player.hidden_by:
-                    self.castplayer.player.hidden_by.remove(group_uuid)
+                self.castplayer.player.active_source = self.castplayer.player.player_id
         self.prov.logger.debug(
             "%s got new cast status for group: %s", self.castplayer.player.display_name, group_uuid
         )
+        self.new_cast_status(self.castplayer.cc.status)
 
     def multizone_new_media_status(self, group_uuid, media_status):  # noqa: ARG002
         """Handle reception of a new MediaStatus for a group."""
index bb76de9fc4f472d7c304e257cd9db62dd3621469..85c69c67ae06b7d1657b4e6b79eb301b27f81c70 100644 (file)
@@ -604,7 +604,7 @@ class YoutubeMusicProvider(MusicProvider):
 
     async def _parse_track(self, track_obj: dict) -> Track:
         """Parse a YT Track response to a Track model object."""
-        if not track_obj["videoId"]:
+        if not track_obj.get("videoId"):
             raise InvalidDataError("Track is missing videoId")
         track = Track(item_id=track_obj["videoId"], provider=self.domain, name=track_obj["title"])
         if "artists" in track_obj:
index 971480c04cf55b731974af938565227d4f3d9695..6e089f2848f4a1d817498be0dce4496f13bab6cc 100644 (file)
@@ -34,7 +34,7 @@ server = [
   "python-slugify==8.0.1",
   "mashumaro==3.5.0",
   "memory-tempfile==2.2.3",
-  "music-assistant-frontend==20230402.0",
+  "music-assistant-frontend==20230404.0",
   "pillow==9.5.0",
   "unidecode==1.3.6",
   "xmltodict==0.13.0",
index 08dd1e8c4583f9123f85c45ca27863cfd7b093be..1227b378577c78e4a17848f3a9e463438303aff0 100644 (file)
@@ -13,7 +13,7 @@ databases==0.7.0
 git+https://github.com/pytube/pytube.git@refs/pull/1501/head
 mashumaro==3.5.0
 memory-tempfile==2.2.3
-music-assistant-frontend==20230402.0
+music-assistant-frontend==20230404.0
 orjson==3.8.9
 pillow==9.5.0
 plexapi==4.13.2