Several small bugfixes (#1348)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 11 Jun 2024 19:26:49 +0000 (21:26 +0200)
committerGitHub <noreply@github.com>
Tue, 11 Jun 2024 19:26:49 +0000 (21:26 +0200)
25 files changed:
music_assistant/common/models/config_entries.py
music_assistant/server/controllers/config.py
music_assistant/server/controllers/media/base.py
music_assistant/server/controllers/media/playlists.py
music_assistant/server/controllers/music.py
music_assistant/server/controllers/players.py
music_assistant/server/controllers/streams.py
music_assistant/server/providers/airplay/__init__.py
music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 [changed mode: 0755->0644]
music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64
music_assistant/server/providers/airplay/bin/cliraop-macos-arm64
music_assistant/server/providers/apple_music/__init__.py
music_assistant/server/providers/builtin/__init__.py
music_assistant/server/providers/deezer/__init__.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/jellyfin/__init__.py
music_assistant/server/providers/opensubsonic/sonic_provider.py
music_assistant/server/providers/plex/__init__.py
music_assistant/server/providers/snapcast/__init__.py
music_assistant/server/providers/sonos/__init__.py
music_assistant/server/providers/soundcloud/__init__.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/tidal/__init__.py
music_assistant/server/providers/ugp/__init__.py
music_assistant/server/providers/ytmusic/__init__.py

index c080f5e526ca7767f3b722652a6f77537b96f973..a51d1e9ae22a5d25e1641cb8a58c588b2f9c3769 100644 (file)
@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import logging
+import warnings
 from collections.abc import Iterable
 from dataclasses import dataclass
 from enum import Enum
@@ -39,6 +40,10 @@ from music_assistant.constants import (
 
 from .enums import ConfigEntryType
 
+# TEMP: ignore UserWarnings from mashumaro
+# https://github.com/Fatal1ty/mashumaro/issues/221
+warnings.filterwarnings("ignore", category=UserWarning, module="mashumaro")
+
 LOGGER = logging.getLogger(__name__)
 
 ENCRYPT_CALLBACK: callable[[str], str] | None = None
@@ -343,6 +348,7 @@ CONF_ENTRY_FLOW_MODE_ENFORCED = ConfigEntry(
     label=CONF_FLOW_MODE,
     default_value=True,
     value=True,
+    hidden=True,
 )
 
 CONF_ENTRY_AUTO_PLAY = ConfigEntry(
@@ -385,7 +391,7 @@ CONF_ENTRY_VOLUME_NORMALIZATION_TARGET = ConfigEntry(
     label="Target level for volume normalization",
     description="Adjust average (perceived) loudness to this target level",
     depends_on=CONF_VOLUME_NORMALIZATION,
-    category="audio",
+    category="advanced",
 )
 
 CONF_ENTRY_EQ_BASS = ConfigEntry(
@@ -447,7 +453,7 @@ CONF_ENTRY_CROSSFADE_DURATION = ConfigEntry(
     label="Crossfade duration",
     description="Duration in seconds of the crossfade between tracks (if enabled)",
     depends_on=CONF_CROSSFADE,
-    category="audio",
+    category="advanced",
 )
 
 CONF_ENTRY_HIDE_PLAYER = ConfigEntry(
index 42b2cf681a7096ad27e8854d5c3e3e6b91b97f7c..95ed6f46212476f077fce08fd7184e92edb05d8b 100644 (file)
@@ -196,7 +196,7 @@ class ConfigController:
     async def get_provider_config_value(self, instance_id: str, key: str) -> ConfigValueType:
         """Return single configentry value for a provider."""
         cache_key = f"prov_conf_value_{instance_id}.{key}"
-        if cached_value := self._value_cache.get(cache_key) is not None:
+        if (cached_value := self._value_cache.get(cache_key)) is not None:
             return cached_value
         conf = await self.get_provider_config(instance_id)
         val = (
@@ -339,12 +339,17 @@ class ConfigController:
     async def get_player_config(self, player_id: str) -> PlayerConfig:
         """Return (full) configuration for a single player."""
         if raw_conf := self.get(f"{CONF_PLAYERS}/{player_id}"):
-            if prov := self.mass.get_provider(raw_conf["provider"]):
+            if player := self.mass.players.get(player_id, False):
+                raw_conf["default_name"] = player.display_name
+                raw_conf["provider"] = player.provider
+                prov = self.mass.get_provider(player.provider)
                 conf_entries = await prov.get_player_config_entries(player_id)
-                if player := self.mass.players.get(player_id, False):
-                    raw_conf["default_name"] = player.display_name
             else:
-                conf_entries = ()
+                # handle unavailable player and/or provider
+                if prov := self.mass.get_provider(raw_conf["provider"]):
+                    conf_entries = await prov.get_player_config_entries(player_id)
+                else:
+                    conf_entries = ()
                 raw_conf["available"] = False
                 raw_conf["name"] = raw_conf.get("name")
                 raw_conf["default_name"] = raw_conf.get("default_name") or raw_conf["player_id"]
index c9ce75cf43c5a8139053ea0df5e2179f3975eeb8..2d9860aafc5c97b260743cdb414f40a467fd68ac 100644 (file)
@@ -25,6 +25,7 @@ from music_assistant.common.models.media_items import (
 from music_assistant.constants import (
     DB_TABLE_ALBUMS,
     DB_TABLE_ARTISTS,
+    DB_TABLE_PLAYLOG,
     DB_TABLE_PROVIDER_MAPPINGS,
     MASS_LOGGER_NAME,
 )
@@ -167,6 +168,24 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
             DB_TABLE_PROVIDER_MAPPINGS,
             {"media_type": self.media_type.value, "item_id": db_id},
         )
+        # cleanup playlog table
+        await self.mass.music.database.delete(
+            DB_TABLE_PLAYLOG,
+            {
+                "media_type": self.media_type.value,
+                "item_id": db_id,
+                "provider": "library",
+            },
+        )
+        for prov_mapping in library_item.provider_mappings:
+            await self.mass.music.database.delete(
+                DB_TABLE_PLAYLOG,
+                {
+                    "media_type": self.media_type.value,
+                    "item_id": prov_mapping.item_id,
+                    "provider": prov_mapping.provider_instance,
+                },
+            )
         # 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, library_item.uri, library_item)
@@ -598,6 +617,15 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
                 "provider_item_id": provider_item_id,
             },
         )
+        # cleanup playlog table
+        await self.mass.music.database.delete(
+            DB_TABLE_PLAYLOG,
+            {
+                "media_type": self.media_type.value,
+                "item_id": provider_item_id,
+                "provider": provider_instance_id,
+            },
+        )
         if library_item.provider_mappings:
             # we (temporary?) duplicate the provider mappings in a separate column of the media
             # item's table, because the json_group_array query is superslow
index 728d4a5522309de02e1a74144eda2f2a32da4503..1ad3b4b8c9146b59ba9826babc0b7ec8f7e9f037 100644 (file)
@@ -85,9 +85,15 @@ class PlaylistController(MediaControllerBase[Playlist]):
                     final_tracks.append(track)
         else:
             final_tracks = tracks
-        # we set total to None as we have no idea how many tracks there are
-        # the frontend can figure this out and stop paging when it gets an empty list
-        return PagedItems(items=final_tracks, limit=limit, offset=offset, total=None)
+        # We set total to None as we have no idea how many tracks there are.
+        # The frontend can figure this out and stop paging when it gets an empty list.
+        # Exception is when we receive a result that is either much higher
+        # or smaller than the limit - in that case we consider the list final.
+        total = None
+        count = len(final_tracks)
+        if count and (count < (limit - 10) or count > (limit + 10)):
+            total = offset + len(final_tracks)
+        return PagedItems(items=final_tracks, limit=limit, offset=offset, total=total, count=count)
 
     async def create_playlist(
         self, name: str, provider_instance_or_domain: str | None = None
@@ -305,6 +311,9 @@ class PlaylistController(MediaControllerBase[Playlist]):
                 break
             if paged_items.count == 0:
                 break
+            if paged_items.total is None and paged_items.items == result:
+                # safety guard for malfunctioning provider
+                break
             offset += paged_items.count
         return result
 
index 41b0acf4b9dc7262dcf457e80dc3aac5f9ed363b..48d7d226f9ab83cf1f82061a925ea8e6faa808bb 100644 (file)
@@ -610,7 +610,7 @@ class MusicController(CoreController):
             prov_key = provider_instance_id_or_domain
 
         # do not try to store dynamic urls (e.g. with auth token etc.),
-        # stick with plaun uri/urls only
+        # stick with plain uri/urls only
         if "http" in item_id and "?" in item_id:
             return
 
@@ -730,6 +730,9 @@ class MusicController(CoreController):
             else:
                 self.logger.info("Sync task for %s completed", provider.name)
             self.mass.signal_event(EventType.SYNC_TASKS_UPDATED, data=self.in_progress_syncs)
+            # schedule db cleanup after sync
+            if not self.in_progress_syncs:
+                self.mass.create_task(self._cleanup_database())
 
         task.add_done_callback(on_sync_task_done)
 
@@ -794,6 +797,14 @@ class MusicController(CoreController):
         if remaining_items_count := await self.database.get_count_from_query(query):
             errors += remaining_items_count
 
+        # cleanup playlog table
+        await self.mass.music.database.delete(
+            DB_TABLE_PLAYLOG,
+            {
+                "provider": provider_instance,
+            },
+        )
+
         if errors == 0:
             # cleanup successful, remove from the deleted_providers setting
             self.logger.info("Provider %s removed from library", provider_instance)
@@ -814,6 +825,35 @@ class MusicController(CoreController):
         # NOTE: sync_interval is stored in minutes, we need seconds
         self.mass.loop.call_later(sync_interval * 60, self._schedule_sync)
 
+    async def _cleanup_database(self) -> None:
+        """Perform database cleanup/maintenance."""
+        self.logger.debug("Performing database cleanup...")
+        # Remove playlog entries older than 90 days
+        await self.database.delete_where_query(
+            DB_TABLE_PLAYLOG, f"timestamp < strftime('%s','now') - {3600 * 24  * 90}"
+        )
+        # db tables cleanup
+        for ctrl in (self.albums, self.artists, self.tracks, self.playlists, self.radio):
+            # Provider mappings where the db item is removed
+            query = (
+                f"item_id not in (SELECT item_id from {ctrl.db_table}) "
+                f"AND media_type = '{ctrl.media_type}'"
+            )
+            await self.database.delete_where_query(DB_TABLE_PROVIDER_MAPPINGS, query)
+            # Orphaned db items
+            query = (
+                f"item_id not in (SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
+                f"WHERE media_type = '{ctrl.media_type}')"
+            )
+            await self.database.delete_where_query(ctrl.db_table, query)
+            # Cleanup removed db items from the playlog
+            where_clause = (
+                f"media_type = '{ctrl.media_type}' AND provider = 'library' "
+                f"AND item_id not in (select item_id from {ctrl.db_table})"
+            )
+            await self.mass.music.database.delete_where_query(DB_TABLE_PLAYLOG, where_clause)
+        self.logger.debug("Database cleanup done")
+
     async def _setup_database(self) -> None:
         """Initialize database."""
         db_path = os.path.join(self.mass.storage_path, "library.db")
index 16b7096a742c70018ee502dbbfec8b7b117840b6..bd545df6a2043f426c3a035164d74cdaeb8377a2 100644 (file)
@@ -604,6 +604,8 @@ class PlayerController(CoreController):
                     "Player %s does not support (un)sync commands", child_player.name
                 )
                 continue
+            if child_player.synced_to and child_player.synced_to == target_player:
+                continue  # already synced to this target
             if child_player.synced_to and child_player.synced_to != target_player:
                 # player already synced to another player, unsync first
                 self.logger.warning(
@@ -620,6 +622,8 @@ class PlayerController(CoreController):
                 continue
             # if we reach here, all checks passed
             final_player_ids.append(child_player_id)
+            # set active source if player is synced
+            child_player.active_source = parent_player.active_source
 
         # forward command to the player provider after all (base) sanity checks
         player_provider = self.get_player_provider(target_player)
@@ -627,12 +631,7 @@ class PlayerController(CoreController):
 
     @api_command("players/cmd/unsync_many")
     async def cmd_unsync_many(self, player_ids: list[str]) -> None:
-        """Handle UNSYNC command for all the given players.
-
-        Remove the given player from any syncgroups it currently is synced to.
-
-            - player_id: player_id of the player to handle the command.
-        """
+        """Handle UNSYNC command for all the given players."""
         # filter all player ids on compatibility and availability
         final_player_ids: UniqueList[str] = UniqueList()
         for player_id in player_ids:
@@ -645,6 +644,8 @@ class PlayerController(CoreController):
                 )
                 continue
             final_player_ids.append(player_id)
+            # reset active source player if is unsynced
+            child_player.active_source = None
 
         if not final_player_ids:
             return
index 443f47403f9d8d91e84e9c8e36feaa2a4d0af12a..b7a97ecf916aa7100c1846abf676abb80b227d89 100644 (file)
@@ -49,6 +49,7 @@ from music_assistant.server.helpers.audio import (
     get_hls_stream,
     get_icy_stream,
     get_player_filter_params,
+    get_silence,
     parse_loudnorm,
     strip_silence,
 )
@@ -738,6 +739,11 @@ class StreamsController(CoreController):
             )
         elif streamdetails.stream_type == StreamType.ICY:
             audio_source = get_icy_stream(self.mass, streamdetails.path, streamdetails)
+            # pad some silence before the radio stream starts to create some headroom
+            # for radio stations that do not provide any look ahead buffer
+            # without this, some radio streams jitter a lot
+            async for chunk in get_silence(2, pcm_format):
+                yield chunk
         elif streamdetails.stream_type == StreamType.HLS:
             audio_source = get_hls_stream(
                 self.mass, streamdetails.path, streamdetails, streamdetails.seek_position
index 03bac3d07809962948baf79a6a02e69a82923bce..39c3f089a177ddc1e96be6d4ec4e57f625ba4692 100644 (file)
@@ -61,7 +61,7 @@ CONF_ENCRYPTION = "encryption"
 CONF_ALAC_ENCODE = "alac_encode"
 CONF_VOLUME_START = "volume_start"
 CONF_PASSWORD = "password"
-
+CONF_BIND_INTERFACE = "bind_interface"
 
 PLAYER_CONFIG_ENTRIES = (
     CONF_ENTRY_FLOW_MODE_ENFORCED,
@@ -138,7 +138,16 @@ async def get_config_entries(
     values: the (intermediate) raw values for config entries sent with the action.
     """
     # ruff: noqa: ARG001
-    return ()  # we do not have any config entries (yet)
+    return (
+        ConfigEntry(
+            key=CONF_BIND_INTERFACE,
+            type=ConfigEntryType.STRING,
+            default_value=mass.streams.publish_ip,
+            label="Bind interface",
+            description="Interface to bind to for Airplay streaming.",
+            category="advanced",
+        ),
+    )
 
 
 def convert_airplay_volume(value: float) -> int:
@@ -216,6 +225,10 @@ class AirplayStream:
         extra_args = []
         player_id = self.airplay_player.player_id
         mass_player = self.mass.players.get(player_id)
+        bind_ip = await self.mass.config.get_provider_config_value(
+            self.prov.instance_id, CONF_BIND_INTERFACE
+        )
+        extra_args += ["-if", bind_ip]
         if self.mass.config.get_raw_player_config_value(player_id, CONF_ENCRYPTION, False):
             extra_args += ["-encrypt"]
         if self.mass.config.get_raw_player_config_value(player_id, CONF_ALAC_ENCODE, True):
@@ -282,19 +295,20 @@ class AirplayStream:
         """Stop playback and cleanup."""
         if self._stopped:
             return
-        if not self._cliraop_proc.closed:
+        if self._cliraop_proc.proc and not self._cliraop_proc.closed:
             await self.send_cli_command("ACTION=STOP")
         self._stopped = True  # set after send_cli command!
         if self.audio_source_task and not self.audio_source_task.done():
             self.audio_source_task.cancel()
-        try:
-            await asyncio.wait_for(self._cliraop_proc.wait(), 5)
-        except TimeoutError:
-            self.prov.logger.warning(
-                "Raop process for %s did not stop in time, is the player offline?",
-                self.airplay_player.player_id,
-            )
-            await self._cliraop_proc.close(True)
+        if self._cliraop_proc.proc:
+            try:
+                await asyncio.wait_for(self._cliraop_proc.wait(), 5)
+            except TimeoutError:
+                self.prov.logger.warning(
+                    "Raop process for %s did not stop in time, is the player offline?",
+                    self.airplay_player.player_id,
+                )
+                await self._cliraop_proc.close(True)
 
         # ffmpeg can sometimes hang due to the connected pipes
         # we handle closing it but it can be a bit slow so do that in the background
@@ -598,7 +612,7 @@ class AirplayProvider(PlayerProvider):
                     # prefer interactive command to our streamer
                     tg.create_task(airplay_player.active_stream.send_cli_command("ACTION=PAUSE"))
 
-    async def play_media(
+    async def play_media(  # noqa: PLR0915
         self,
         player_id: str,
         media: PlayerMedia,
@@ -628,8 +642,16 @@ class AirplayProvider(PlayerProvider):
             ugp_stream = ugp_provider.streams[media.queue_id]
             input_format = ugp_stream.audio_format
             audio_source = ugp_stream.subscribe_raw()
+        elif media.media_type == MediaType.RADIO and media.queue_id and media.queue_item_id:
+            # radio stream - consume media stream directly
+            input_format = AIRPLAY_PCM_FORMAT
+            queue_item = self.mass.player_queues.get_item(media.queue_id, media.queue_item_id)
+            audio_source = self.mass.streams.get_media_stream(
+                streamdetails=queue_item.streamdetails,
+                pcm_format=AIRPLAY_PCM_FORMAT,
+            )
         elif media.queue_id and media.queue_item_id:
-            # regular queue stream request
+            # regular queue (flow) stream request
             input_format = AIRPLAY_PCM_FORMAT
             audio_source = self.mass.streams.get_flow_stream(
                 queue=self.mass.player_queues.get(media.queue_id),
old mode 100755 (executable)
new mode 100644 (file)
index c78ba17..6471e7b
Binary files a/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 and b/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 differ
index 0d1d79f90937c8d6fa39d398154bcd472c721bac..fe4b716b1bc113327bd108fe0cd91b81353c10bb 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 and b/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 differ
index 2bd3bfb4555ee12af3ef9290667e3873506adad1..0424e6533fbe2f2ef92a2eecde331e787d9ad981 100755 (executable)
Binary files a/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 and b/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 differ
index ec269d97ff974e4f59276bd236b64d53d51ef231..4e41ccde2a7c6971d2bb23b3a40105631e558f2b 100644 (file)
@@ -567,6 +567,10 @@ class AppleMusicProvider(MusicProvider):
             # Convert HTTP errors to exceptions
             if response.status == 404:
                 raise MediaNotFoundError(f"{endpoint} not found")
+            if response.status == 504:
+                # See if we can get more info from the response on occasional timeouts
+                self.logger.debug("Apple Music API Timeout: %s", response.json(loads=json_loads))
+                raise ResourceTemporarilyUnavailable("Apple Music API Timeout")
             if response.status == 429:
                 # Debug this for now to see if the response headers give us info about the
                 # backoff time. There is no documentation on this.
index 5c0d6fa21f97d68d5c238c3c47710db82a9ca6f9..d9f01cc9bde25f37b6ee92c76035465a77925344 100644 (file)
@@ -374,7 +374,9 @@ class BuiltinProvider(MusicProvider):
                 track.position = offset + index
                 result.append(track)
             except (MediaNotFoundError, InvalidDataError, ProviderUnavailableError) as err:
-                self.logger.warning("Skipping item in playlist: %s:%s", uri, str(err))
+                self.logger.warning(
+                    "Skipping %s in playlist %s: %s", uri, prov_playlist_id, str(err)
+                )
         return result
 
     async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
index 0e87992fdf1c643567f52fe195f3db4d804b1ce6..cf27a1c4bfb1f403bbd97a79f44de646d209b465 100644 (file)
@@ -325,9 +325,10 @@ class DeezerProvider(MusicProvider):  # pylint: disable=W0223
     ) -> list[Track]:
         """Get playlist tracks."""
         result: list[Track] = []
-        # TODO: implement pagination!
+        # TODO: access the underlying paging on the deezer api instead of this hack
         playlist = await self.client.get_playlist(int(prov_playlist_id))
-        for index, deezer_track in enumerate(await playlist.get_tracks(offset=offset, limit=limit)):
+        playlist_tracks = await playlist.get_tracks()
+        for index, deezer_track in enumerate(playlist_tracks[offset : offset + limit], 1):
             result.append(
                 self.parse_track(
                     track=deezer_track,
index 7e563e43a7e46a37003b1f3e553c388d05d3e4fd..2aadc23a2ef9b05829e5683985c45bc2ed7b11f0 100644 (file)
@@ -44,7 +44,6 @@ from music_assistant.constants import (
     DB_TABLE_ALBUM_TRACKS,
     DB_TABLE_ALBUMS,
     DB_TABLE_ARTISTS,
-    DB_TABLE_PLAYLOG,
     DB_TABLE_PROVIDER_MAPPINGS,
     DB_TABLE_TRACK_ARTISTS,
     VARIOUS_ARTISTS_NAME,
@@ -403,12 +402,12 @@ class FileSystemProviderBase(MusicProvider):
 
     async def _process_orphaned_albums_and_artists(self) -> None:
         """Process deletion of orphaned albums and artists."""
-        # process orphaned albums and artists
-
         # Remove albums without any tracks
         query = (
             f"SELECT item_id FROM {DB_TABLE_ALBUMS} "
-            f"WHERE item_id not in (select album_id from {DB_TABLE_ALBUM_TRACKS})"
+            f"WHERE item_id not in ( SELECT album_id from {DB_TABLE_ALBUM_TRACKS}) "
+            f"AND item_id in ( SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
+            f"WHERE provider_instance = '{self.instance_id}' and media_type = 'album' )"
         )
         for db_row in await self.mass.music.database.get_rows_from_query(
             query,
@@ -419,11 +418,11 @@ class FileSystemProviderBase(MusicProvider):
         # Remove artists without any tracks or albums
         query = (
             f"SELECT item_id FROM {DB_TABLE_ARTISTS} "
-            f"WHERE item_id not in ("
-            f"select artist_id from {DB_TABLE_TRACK_ARTISTS} "
-            f"UNION "
-            f"select artist_id from {DB_TABLE_ALBUM_ARTISTS}"
-            ")"
+            f"WHERE item_id not in "
+            f"select artist_id from {DB_TABLE_TRACK_ARTISTS} "
+            f"UNION SELECT artist_id from {DB_TABLE_ALBUM_ARTISTS} )"
+            f"AND item_id in ( SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
+            f"WHERE provider_instance = '{self.instance_id}' and media_type = 'artist' )"
         )
         for db_row in await self.mass.music.database.get_rows_from_query(
             query,
@@ -431,36 +430,6 @@ class FileSystemProviderBase(MusicProvider):
         ):
             await self.mass.music.artists.remove_item_from_library(db_row["item_id"])
 
-        # Provider mappings where the album is removed
-        query = (
-            f"SELECT item_id FROM {DB_TABLE_PROVIDER_MAPPINGS} "
-            f"WHERE media_type = 'album' "
-            f"and item_id not in (select item_id from {DB_TABLE_ALBUMS})"
-        )
-        for db_row in await self.mass.music.database.get_rows_from_query(query, limit=100000):
-            await self.mass.music.albums.remove_provider_mappings(
-                db_row["item_id"], self.instance_id
-            )
-
-        # Provider mappings where the artist is removed
-        query = (
-            f"SELECT item_id FROM {DB_TABLE_PROVIDER_MAPPINGS} "
-            f"WHERE media_type = 'artist' "
-            f"and item_id not in (select item_id from {DB_TABLE_ARTISTS})"
-        )
-        for db_row in await self.mass.music.database.get_rows_from_query(query, limit=100000):
-            await self.mass.music.artists.remove_provider_mappings(
-                db_row["item_id"], self.instance_id
-            )
-
-        # Remove albums that are removed from the playlog
-        where_clause = (
-            f"media_type = 'album' "
-            f"and provider = '{self.instance_id}' "
-            f"and item_id not in (select item_id from albums)"
-        )
-        await self.mass.music.database.delete_where_query(DB_TABLE_PLAYLOG, where_clause)
-
     async def _process_deletions(self, deleted_files: set[str]) -> None:
         """Process all deletions."""
         # process deleted tracks/playlists
index f2a7a78bf0aa3a135db6e1776bd1e2a569bb3464..860d5723ea4f0deb6af9891bf0a643d644004a72 100644 (file)
@@ -705,11 +705,11 @@ class JellyfinProvider(MusicProvider):
         )\r
         if not playlist_items:\r
             return result\r
-        for index, jellyfin_track in enumerate(playlist_items[offset : offset + limit]):\r
+        for index, jellyfin_track in enumerate(playlist_items[offset : offset + limit], 1):\r
             try:\r
                 if track := await self._parse_track(jellyfin_track):\r
                     if not track.position:\r
-                        track.position = index\r
+                        track.position = offset + index\r
                     result.append(track)\r
             except (KeyError, ValueError) as err:\r
                 self.logger.error(\r
index 0c35318e1791842f6599f3311bdc4dd19096865d..f2ad873e0a080d0e00158abe65f35ed3b3df4240 100644 (file)
@@ -665,9 +665,10 @@ class OpenSonicProvider(MusicProvider):
         except (ParameterError, DataNotFoundError) as e:
             msg = f"Playlist {prov_playlist_id} not found"
             raise MediaNotFoundError(msg) from e
-        for index, sonic_song in enumerate(sonic_playlist.songs[offset : offset + limit]):
+        # TODO: figure out if subsonic supports paging here
+        for index, sonic_song in enumerate(sonic_playlist.songs[offset : offset + limit], 1):
             track = self._parse_track(sonic_song)
-            track.position = index
+            track.position = offset + index
             result.append(track)
         return result
 
index 3ef3a30400e18c774846b2ccbd62b6f7cd40674c..81a73e394f35bede6c4dc071f3b7e9a305a1254a 100644 (file)
@@ -5,6 +5,7 @@ from __future__ import annotations
 import asyncio
 import logging
 from asyncio import TaskGroup
+from contextlib import suppress
 from typing import TYPE_CHECKING
 
 import plexapi.exceptions
@@ -499,11 +500,10 @@ class PlexProvider(MusicProvider):
         )
         # Only add 5-star rated albums to Favorites. rating will be 10.0 for those.
         # TODO: Let user set threshold?
-        try:
+        with suppress(KeyError):
+            # suppress KeyError (as it doesn't exist for items without rating),
+            # allow sync to continue
             album.favorite = plex_album._data.attrib["userRating"] == "10.0"
-        except KeyError:
-            # Log but suppress exception, allow sync to continue
-            self.logger.error("ERROR: %s has no rating", plex_album.title)
 
         if plex_album.year:
             album.year = plex_album.year
@@ -620,11 +620,10 @@ class PlexProvider(MusicProvider):
         )
         # Only add 5-star rated tracks to Favorites. userRating will be 10.0 for those.
         # TODO: Let user set threshold?
-        try:
+        with suppress(KeyError):
+            # suppress KeyError (as it doesn't exist for items without rating),
+            # allow sync to continue
             track.favorite = plex_track._data.attrib["userRating"] == "10.0"
-        except KeyError:
-            # Log but suppress exception, allow sync to continue
-            self.logger.error("ERROR: %s has no userRating", plex_track.title)
 
         if plex_track.originalTitle and plex_track.originalTitle != plex_track.grandparentTitle:
             # The artist of the track if different from the album's artist.
@@ -814,7 +813,7 @@ class PlexProvider(MusicProvider):
         plex_playlist: PlexPlaylist = await self._get_data(prov_playlist_id, PlexPlaylist)
         if not (playlist_items := await self._run_async(plex_playlist.items)):
             return result
-        for index, plex_track in enumerate(playlist_items[offset : offset + limit]):
+        for index, plex_track in enumerate(playlist_items[offset : offset + limit], 1):
             if track := await self._parse_track(plex_track):
                 track.position = index
                 result.append(track)
index f45b4a2b3caea2c637c1f58ee784a1afff4c4131..7ea38824c5bc52f42df9eb1e018a46770f9e016d 100644 (file)
@@ -355,7 +355,7 @@ class SnapCastProvider(PlayerProvider):
         await self._get_snapgroup(player_id).set_stream("default")
         self._handle_update()
 
-    async def play_media(self, player_id: str, media: PlayerMedia) -> None:
+    async def play_media(self, player_id: str, media: PlayerMedia) -> None:  # noqa: PLR0915
         """Handle PLAY MEDIA on given player."""
         player = self.mass.players.get(player_id)
         if player.synced_to:
@@ -382,8 +382,16 @@ class SnapCastProvider(PlayerProvider):
             ugp_stream = ugp_provider.streams[media.queue_id]
             input_format = ugp_stream.audio_format
             audio_source = ugp_stream.subscribe_raw()
+        elif media.media_type == MediaType.RADIO and media.queue_id and media.queue_item_id:
+            # radio stream - consume media stream directly
+            input_format = DEFAULT_SNAPCAST_FORMAT
+            queue_item = self.mass.player_queues.get_item(media.queue_id, media.queue_item_id)
+            audio_source = self.mass.streams.get_media_stream(
+                streamdetails=queue_item.streamdetails,
+                pcm_format=DEFAULT_SNAPCAST_FORMAT,
+            )
         elif media.queue_id and media.queue_item_id:
-            # regular queue stream request
+            # regular queue (flow) stream request
             input_format = DEFAULT_SNAPCAST_FORMAT
             audio_source = self.mass.streams.get_flow_stream(
                 queue=self.mass.player_queues.get(media.queue_id),
index a42ef0e31cdf8382a7c0f092278a585f37a9f88c..bdc60f98f59a0999954d5a22029927e5caf8971e 100644 (file)
@@ -315,6 +315,13 @@ class SonosPlayerProvider(PlayerProvider):
 
         await asyncio.to_thread(set_volume_mute, player_id, muted)
 
+    async def cmd_sync_many(self, target_player: str, child_player_ids: list[str]) -> None:
+        """Create temporary sync group by joining given players to target player."""
+        sonos_master_player = self.sonosplayers[target_player]
+        await sonos_master_player.join(
+            [self.sonosplayers[player_id] for player_id in child_player_ids]
+        )
+
     async def cmd_sync(self, player_id: str, target_player: str) -> None:
         """Handle SYNC command for given player.
 
index 7148fc47fabb0f3373fe1a399b603c59a9bf9cfc..a73617dc4cd96e56750a6326b976cc3a2d97d14c 100644 (file)
@@ -264,7 +264,7 @@ class SoundcloudMusicProvider(MusicProvider):
         playlist_obj = await self._soundcloud.get_playlist_details(playlist_id=prov_playlist_id)
         if "tracks" not in playlist_obj:
             return result
-        for index, item in enumerate(playlist_obj["tracks"][offset : offset + limit]):
+        for index, item in enumerate(playlist_obj["tracks"][offset : offset + limit], 1):
             song = await self._soundcloud.get_track_details(item["id"])
             try:
                 # TODO: is it really needed to grab the entire track with an api call ?
index bf87f12008d5cf69ce752e355050bd81800c4974..87035247b9ca58a51261e520d401f7040ca83499 100644 (file)
@@ -324,7 +324,7 @@ class SpotifyProvider(MusicProvider):
             else f"playlists/{prov_playlist_id}/tracks"
         )
         spotify_result = await self._get_data(uri, limit=limit, offset=offset)
-        for index, item in enumerate(spotify_result["items"]):
+        for index, item in enumerate(spotify_result["items"], 1):
             if not (item and item["track"] and item["track"]["id"]):
                 continue
             # use count as position
index e2cf785b7b6f252d110ddd367fe20ef3897a85ce..6eaf3269278364da7faf7ea6dacf9e115abda7f5 100644 (file)
@@ -356,9 +356,10 @@ class TidalProvider(MusicProvider):
         tidal_session = await self._get_tidal_session()
         result: list[Track] = []
         track_obj: TidalTrack  # satisfy the type checker
-        for index, track_obj in enumerate(
-            await get_playlist_tracks(tidal_session, prov_playlist_id, limit=limit, offset=offset)
-        ):
+        tidal_tracks = await get_playlist_tracks(
+            tidal_session, prov_playlist_id, limit=limit, offset=offset
+        )
+        for index, track_obj in enumerate(tidal_tracks, 1):
             track = self._parse_track(track_obj=track_obj)
             track.position = offset + index
             result.append(track)
index ff0024ba4dfb43b648d3e1efb5c8d062830318a7..37a450c7e66b2111a6df015883ed49a7012fa4c9 100644 (file)
@@ -137,6 +137,7 @@ class UniversalGroupProvider(PlayerProvider):
                 label="Please note that although the universal group "
                 "allows you to group any player, it will not enable audio sync "
                 "between players of different ecosystems.",
+                required=False,
             ),
             CONF_ENTRY_CROSSFADE,
             CONF_ENTRY_CROSSFADE_DURATION,
index 8ee08ddda20669a4145f15831ec7468725ccc4cd..4a772fa9f48e28fbfcb2ea1f3f8a0fe416697eef 100644 (file)
@@ -371,7 +371,8 @@ class YoutubeMusicProvider(MusicProvider):
         if "tracks" not in playlist_obj:
             return None
         result = []
-        for index, track_obj in enumerate(playlist_obj["tracks"]):
+        # TODO: figure out how to handle paging in YTM
+        for index, track_obj in enumerate(playlist_obj["tracks"][offset : offset + limit]):
             if track_obj["isAvailable"]:
                 # Playlist tracks sometimes do not have a valid artist id
                 # In that case, call the API for track details based on track id
@@ -407,8 +408,7 @@ class YoutubeMusicProvider(MusicProvider):
         artist_obj = await get_artist(prov_artist_id=prov_artist_id, headers=self._headers)
         if artist_obj.get("songs") and artist_obj["songs"].get("browseId"):
             prov_playlist_id = artist_obj["songs"]["browseId"]
-            playlist_tracks = await self.get_playlist_tracks(prov_playlist_id, 0, 0)
-            return playlist_tracks[:25]
+            return await self.get_playlist_tracks(prov_playlist_id, 0, 25)
         return []
 
     async def library_add(self, item: MediaItemType) -> bool: