Add feature of starting a radio based on artist, album, playlist or track (#442)
authorMarvin Schenkel <marvinschenkel@gmail.com>
Fri, 29 Jul 2022 08:07:16 +0000 (10:07 +0200)
committerGitHub <noreply@github.com>
Fri, 29 Jul 2022 08:07:16 +0000 (10:07 +0200)
* Initial implementation of radio feature thoughout the code base
* Implement similar tracks for YT Music
* Implement similar tracks for Spotify

This MVP implementation only provides the radio feature when there is a streaming provider (spotify or YTM) attached to a media item. The frontend will need to be guarded so that radio can not be started if there's no supported streaming provider.

Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
15 files changed:
examples/full.py
music_assistant/controllers/music/albums.py
music_assistant/controllers/music/artists.py
music_assistant/controllers/music/playlists.py
music_assistant/controllers/music/radio.py
music_assistant/controllers/music/tracks.py
music_assistant/helpers/cache.py
music_assistant/models/enums.py
music_assistant/models/errors.py
music_assistant/models/media_controller.py
music_assistant/models/music_provider.py
music_assistant/models/player_queue.py
music_assistant/music_providers/spotify.py
music_assistant/music_providers/ytmusic/helpers.py
music_assistant/music_providers/ytmusic/ytmusic.py

index 592ea29259a654025f2308f2cc45f7e4a9c0f066..b2cb26d7d38410f8e57cfa2d43b1374e8e3c375a 100644 (file)
@@ -201,19 +201,30 @@ async def main():
         await mass.music.start_sync()
 
         # get some data
-        artist_count = await mass.music.artists.count()
-        artist_count_lib = await mass.music.artists.count(True)
-        print(f"Got {artist_count} artists ({artist_count_lib} in library)")
-        album_count = await mass.music.albums.count()
-        album_count_lib = await mass.music.albums.count(True)
-        print(f"Got {album_count} albums ({album_count_lib} in library)")
-        track_count = await mass.music.tracks.count()
-        track_count_lib = await mass.music.tracks.count(True)
-        print(f"Got {track_count} tracks ({track_count_lib} in library)")
-        radio_count = await mass.music.radio.count(True)
-        print(f"Got {radio_count} radio stations in library")
-        playlists = await mass.music.playlists.db_items(True)
-        print(f"Got {len(playlists)} playlists in library")
+        artists = await mass.music.artists.db_items()
+        artists_lib = await mass.music.artists.db_items(True)
+        print(
+            f"Got {artists_lib.total} artists  in library (of {artists.total} total in db)"
+        )
+
+        albums = await mass.music.albums.db_items()
+        albums_lib = await mass.music.albums.db_items(True)
+        print(
+            f"Got {albums_lib.total} albums  in library (of {albums.total} total in db)"
+        )
+
+        tracks = await mass.music.tracks.db_items()
+        tracks_lib = await mass.music.tracks.db_items(True)
+        print(
+            f"Got {tracks_lib.total} tracks  in library (of {tracks.total} total in db)"
+        )
+
+        playlists = await mass.music.playlists.db_items()
+        playlists_lib = await mass.music.playlists.db_items(True)
+        print(
+            f"Got {playlists_lib.total} tracks  in library (of {playlists.total} total in db)"
+        )
+
         # register a player
         test_player1 = TestPlayer("test1")
         test_player2 = TestPlayer("test2")
@@ -230,8 +241,8 @@ async def main():
         # we can also send an uri, such as spotify://track/abcdfefgh
         # or database://playlist/1
         # or a list of items
-        if len(playlists) > 0:
-            await test_player1.active_queue.play_media(playlists[0])
+        if playlists.count > 0:
+            await test_player1.active_queue.play_media(playlists.items[0])
 
         await asyncio.sleep(3600)
 
index 773d218ee82e2d0c3c99cc76dddd521e95e7f2bc..9aa3cbfdabb2d75feb84b38afce1a4e216bcb3c4 100644 (file)
@@ -2,6 +2,7 @@
 from __future__ import annotations
 
 import asyncio
+from random import choice, random
 from typing import List, Optional, Union
 
 from music_assistant.constants import VARIOUS_ARTISTS
@@ -9,7 +10,10 @@ from music_assistant.helpers.compare import compare_album, loose_compare_strings
 from music_assistant.helpers.database import TABLE_ALBUMS, TABLE_TRACKS
 from music_assistant.helpers.json import json_serializer
 from music_assistant.models.enums import EventType, MusicProviderFeature, ProviderType
-from music_assistant.models.errors import MediaNotFoundError
+from music_assistant.models.errors import (
+    MediaNotFoundError,
+    UnsupportedFeaturedException,
+)
 from music_assistant.models.event import MassEvent
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import (
@@ -112,62 +116,6 @@ class AlbumsController(MediaControllerBase[Album]):
         )
         return db_item
 
-    async def _get_provider_album_tracks(
-        self,
-        item_id: str,
-        provider: Optional[ProviderType] = None,
-        provider_id: Optional[str] = None,
-    ) -> List[Track]:
-        """Return album tracks for the given provider album id."""
-        prov = self.mass.music.get_provider(provider_id or provider)
-        if not prov:
-            return []
-        full_album = await self.get_provider_item(item_id, provider_id or provider)
-        # prefer cache items (if any)
-        cache_key = f"{prov.type.value}.albumtracks.{item_id}"
-        cache_checksum = full_album.metadata.checksum
-        if cache := await self.mass.cache.get(cache_key, checksum=cache_checksum):
-            return [Track.from_dict(x) for x in cache]
-        # no items in cache - get listing from provider
-        items = []
-        for track in await prov.get_album_tracks(item_id):
-            # make sure that the (full) album is stored on the tracks
-            track.album = full_album
-            if full_album.metadata.images:
-                track.metadata.images = full_album.metadata.images
-            items.append(track)
-        # store (serializable items) in cache
-        self.mass.create_task(
-            self.mass.cache.set(
-                cache_key, [x.to_dict() for x in items], checksum=cache_checksum
-            )
-        )
-        return items
-
-    async def _get_db_album_tracks(
-        self,
-        item_id: str,
-    ) -> List[Track]:
-        """Return in-database album tracks for the given database album."""
-        db_album = await self.get_db_item(item_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 tracks WHERE albums LIKE '%\"{item_id}\"%'"
-        result = []
-        for track in await self.mass.music.tracks.get_db_items_by_query(query):
-            if album_mapping := next(
-                (x for x in track.albums if x.item_id == db_album.item_id), None
-            ):
-                # make sure that the full album is set on the track and prefer the album's images
-                track.album = db_album
-                if db_album.metadata.images:
-                    track.metadata.images = db_album.metadata.images
-                # apply the disc and track number from the mapping
-                track.disc_number = album_mapping.disc_number
-                track.track_number = album_mapping.track_number
-                result.append(track)
-        return sorted(result, key=lambda x: (x.disc_number or 0, x.track_number or 0))
-
     async def add_db_item(self, item: Album, overwrite_existing: bool = False) -> Album:
         """Add a new record to the database."""
         assert item.provider_ids, f"Album {item.name} is missing provider id(s)"
@@ -283,6 +231,103 @@ class AlbumsController(MediaControllerBase[Album]):
         # delete the album itself from db
         await super().delete_db_item(item_id)
 
+    async def _get_provider_album_tracks(
+        self,
+        item_id: str,
+        provider: Optional[ProviderType] = None,
+        provider_id: Optional[str] = None,
+    ) -> List[Track]:
+        """Return album tracks for the given provider album id."""
+        prov = self.mass.music.get_provider(provider_id or provider)
+        if not prov:
+            return []
+        full_album = await self.get_provider_item(item_id, provider_id or provider)
+        # prefer cache items (if any)
+        cache_key = f"{prov.type.value}.albumtracks.{item_id}"
+        cache_checksum = full_album.metadata.checksum
+        if cache := await self.mass.cache.get(cache_key, checksum=cache_checksum):
+            return [Track.from_dict(x) for x in cache]
+        # no items in cache - get listing from provider
+        items = []
+        for track in await prov.get_album_tracks(item_id):
+            # make sure that the (full) album is stored on the tracks
+            track.album = full_album
+            if full_album.metadata.images:
+                track.metadata.images = full_album.metadata.images
+            items.append(track)
+        # store (serializable items) in cache
+        self.mass.create_task(
+            self.mass.cache.set(
+                cache_key, [x.to_dict() for x in items], checksum=cache_checksum
+            )
+        )
+        return items
+
+    async def _get_provider_dynamic_tracks(
+        self,
+        item_id: str,
+        provider: Optional[ProviderType] = None,
+        provider_id: Optional[str] = None,
+        limit: int = 25,
+    ):
+        """Generate a dynamic list of tracks based on the album content."""
+        prov = self.mass.music.get_provider(provider_id or provider)
+        if (
+            not prov
+            or MusicProviderFeature.SIMILAR_TRACKS not in prov.supported_features
+        ):
+            return []
+        album_tracks = await self._get_provider_album_tracks(
+            item_id=item_id, provider=provider, provider_id=provider_id
+        )
+        # Grab a random track from the album that we use to obtain similar tracks for
+        track = choice(album_tracks)
+        # Calculate no of songs to grab from each list at a 10/90 ratio
+        total_no_of_tracks = limit + limit % 2
+        no_of_album_tracks = int(total_no_of_tracks * 10 / 100)
+        no_of_similar_tracks = int(total_no_of_tracks * 90 / 100)
+        # Grab similar tracks from the music provider
+        similar_tracks = await prov.get_similar_tracks(
+            prov_track_id=track.item_id, limit=no_of_similar_tracks
+        )
+        # Merge album content with similar tracks
+        dynamic_playlist = [
+            *sorted(album_tracks, key=lambda n: random())[:no_of_album_tracks],
+            *sorted(similar_tracks, key=lambda n: random())[:no_of_similar_tracks],
+        ]
+        return sorted(dynamic_playlist, key=lambda n: random())
+
+    async def _get_dynamic_tracks(self, media_item: Album, limit=25) -> List[Track]:
+        """Get dynamic list of tracks for given item, fallback/default implementation."""
+        # TODO: query metadata provider(s) to get similar tracks (or tracks from similar artists)
+        raise UnsupportedFeaturedException(
+            "No Music Provider found that supports requesting similar tracks."
+        )
+
+    async def _get_db_album_tracks(
+        self,
+        item_id: str,
+    ) -> List[Track]:
+        """Return in-database album tracks for the given database album."""
+        db_album = await self.get_db_item(item_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 tracks WHERE albums LIKE '%\"{item_id}\"%'"
+        result = []
+        for track in await self.mass.music.tracks.get_db_items_by_query(query):
+            if album_mapping := next(
+                (x for x in track.albums if x.item_id == db_album.item_id), None
+            ):
+                # make sure that the full album is set on the track and prefer the album's images
+                track.album = db_album
+                if db_album.metadata.images:
+                    track.metadata.images = db_album.metadata.images
+                # apply the disc and track number from the mapping
+                track.disc_number = album_mapping.disc_number
+                track.track_number = album_mapping.track_number
+                result.append(track)
+        return sorted(result, key=lambda x: (x.disc_number or 0, x.track_number or 0))
+
     async def _match(self, db_album: Album) -> None:
         """
         Try to find matching album on all providers for the provided (database) album.
index f6f69567eb584c982e809e035061d7badd42d0e3..e38fd4c2da160cbd005a64fa948500de431f50a9 100644 (file)
@@ -2,6 +2,7 @@
 
 import asyncio
 import itertools
+from random import choice, random
 from time import time
 from typing import Any, Dict, List, Optional
 
@@ -10,7 +11,10 @@ from music_assistant.helpers.compare import compare_strings
 from music_assistant.helpers.database import TABLE_ALBUMS, TABLE_ARTISTS, TABLE_TRACKS
 from music_assistant.helpers.json import json_serializer
 from music_assistant.models.enums import EventType, MusicProviderFeature, ProviderType
-from music_assistant.models.errors import MediaNotFoundError
+from music_assistant.models.errors import (
+    MediaNotFoundError,
+    UnsupportedFeaturedException,
+)
 from music_assistant.models.event import MassEvent
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import (
@@ -348,6 +352,49 @@ class ArtistsController(MediaControllerBase[Artist]):
         # delete the artist itself from db
         await super().delete_db_item(item_id)
 
+    async def _get_provider_dynamic_tracks(
+        self,
+        item_id: str,
+        provider: Optional[ProviderType] = None,
+        provider_id: Optional[str] = None,
+        limit: int = 25,
+    ):
+        """Generate a dynamic list of tracks based on the artist's top tracks."""
+        prov = self.mass.music.get_provider(provider_id or provider)
+        if (
+            not prov
+            or MusicProviderFeature.SIMILAR_TRACKS not in prov.supported_features
+        ):
+            return []
+        top_tracks = await self.get_provider_artist_toptracks(
+            item_id=item_id, provider=provider, provider_id=provider_id
+        )
+        # Grab a random track from the album that we use to obtain similar tracks for
+        track = choice(top_tracks)
+        # Calculate no of songs to grab from each list at a 10/90 ratio
+        total_no_of_tracks = limit + limit % 2
+        no_of_artist_tracks = int(total_no_of_tracks * 10 / 100)
+        no_of_similar_tracks = int(total_no_of_tracks * 90 / 100)
+        # Grab similar tracks from the music provider
+        similar_tracks = await prov.get_similar_tracks(
+            prov_track_id=track.item_id, limit=no_of_similar_tracks
+        )
+        # Merge album content with similar tracks
+        dynamic_playlist = [
+            *sorted(top_tracks, key=lambda n: random())[:no_of_artist_tracks],
+            *sorted(similar_tracks, key=lambda n: random())[:no_of_similar_tracks],
+        ]
+        return sorted(dynamic_playlist, key=lambda n: random())
+
+    async def _get_dynamic_tracks(
+        self, media_item: Artist, limit: int = 25
+    ) -> List[Track]:
+        """Get dynamic list of tracks for given item, fallback/default implementation."""
+        # TODO: query metadata provider(s) to get similar tracks (or tracks from similar artists)
+        raise UnsupportedFeaturedException(
+            "No Music Provider found that supports requesting similar tracks."
+        )
+
     async def _match(self, db_artist: Artist, provider: MusicProvider) -> bool:
         """Try to find matching artists on given provider for the provided (database) artist."""
         self.logger.debug(
index 74acaf3a4bbe281b40fb0bcf9bb912dfb5bcb174..a27b6426e4ace31d441def9740c8583ee5b5c3fb 100644 (file)
@@ -2,6 +2,7 @@
 from __future__ import annotations
 
 from ctypes import Union
+from random import choice, random
 from time import time
 from typing import Any, List, Optional, Tuple
 
@@ -18,6 +19,7 @@ from music_assistant.models.errors import (
     InvalidDataError,
     MediaNotFoundError,
     ProviderUnavailableError,
+    UnsupportedFeaturedException,
 )
 from music_assistant.models.event import MassEvent
 from music_assistant.models.media_controller import MediaControllerBase
@@ -44,43 +46,13 @@ class PlaylistController(MediaControllerBase[Playlist]):
         """Return playlist tracks for the given provider playlist id."""
         playlist = await self.get(item_id, provider, provider_id)
         prov = next(x for x in playlist.provider_ids)
-        return await self.get_provider_playlist_tracks(
+        return await self._get_provider_playlist_tracks(
             prov.item_id,
             provider=prov.prov_type,
             provider_id=prov.prov_id,
             cache_checksum=playlist.metadata.checksum,
         )
 
-    async def get_provider_playlist_tracks(
-        self,
-        item_id: str,
-        provider: Optional[ProviderType] = None,
-        provider_id: Optional[str] = None,
-        cache_checksum: Any = None,
-    ) -> List[Track]:
-        """Return album tracks for the given provider album id."""
-        prov = self.mass.music.get_provider(provider_id or provider)
-        if not prov:
-            return []
-        # prefer cache items (if any)
-        cache_key = f"{prov.id}.playlist.{item_id}.tracks"
-        if cache := await self.mass.cache.get(cache_key, checksum=cache_checksum):
-            return [Track.from_dict(x) for x in cache]
-        # no items in cache - get listing from provider
-        items = await prov.get_playlist_tracks(item_id)
-        # double check if position set
-        if items:
-            assert (
-                items[0].position is not None
-            ), "Playlist items require position to be set"
-        # store (serializable items) in cache
-        self.mass.create_task(
-            self.mass.cache.set(
-                cache_key, [x.to_dict() for x in items], checksum=cache_checksum
-            )
-        )
-        return items
-
     async def add(self, item: Playlist) -> Playlist:
         """Add playlist to local db and return the new database item."""
         item.metadata.last_refresh = int(time())
@@ -280,3 +252,75 @@ class PlaylistController(MediaControllerBase[Playlist]):
         )
         self.logger.debug("updated %s in database: %s", item.name, item_id)
         return await self.get_db_item(item_id)
+
+    async def _get_provider_playlist_tracks(
+        self,
+        item_id: str,
+        provider: Optional[ProviderType] = None,
+        provider_id: Optional[str] = None,
+        cache_checksum: Any = None,
+    ) -> List[Track]:
+        """Return album tracks for the given provider album id."""
+        prov = self.mass.music.get_provider(provider_id or provider)
+        if not prov:
+            return []
+        # prefer cache items (if any)
+        cache_key = f"{prov.id}.playlist.{item_id}.tracks"
+        if cache := await self.mass.cache.get(cache_key, checksum=cache_checksum):
+            return [Track.from_dict(x) for x in cache]
+        # no items in cache - get listing from provider
+        items = await prov.get_playlist_tracks(item_id)
+        # double check if position set
+        if items:
+            assert (
+                items[0].position is not None
+            ), "Playlist items require position to be set"
+        # store (serializable items) in cache
+        self.mass.create_task(
+            self.mass.cache.set(
+                cache_key, [x.to_dict() for x in items], checksum=cache_checksum
+            )
+        )
+        return items
+
+    async def _get_provider_dynamic_tracks(
+        self,
+        item_id: str,
+        provider: Optional[ProviderType] = None,
+        provider_id: Optional[str] = None,
+        limit: int = 25,
+    ):
+        """Generate a dynamic list of tracks based on the playlist content."""
+        prov = self.mass.music.get_provider(provider_id or provider)
+        if (
+            not prov
+            or MusicProviderFeature.SIMILAR_TRACKS not in prov.supported_features
+        ):
+            return []
+        playlist_tracks = await self._get_provider_playlist_tracks(
+            item_id=item_id, provider=provider, provider_id=provider_id
+        )
+        # Grab a random track from the playlist that we use to obtain similar tracks for
+        track = choice(playlist_tracks)
+        # Calculate no of songs to grab from each list at a 50/50 ratio
+        total_no_of_tracks = limit + limit % 2
+        tracks_per_list = int(total_no_of_tracks / 2)
+        # Grab similar tracks from the music provider
+        similar_tracks = await prov.get_similar_tracks(
+            prov_track_id=track.item_id, limit=tracks_per_list
+        )
+        # Merge playlist content with similar tracks
+        dynamic_playlist = [
+            *sorted(playlist_tracks, key=lambda n: random())[:tracks_per_list],
+            *sorted(similar_tracks, key=lambda n: random())[:tracks_per_list],
+        ]
+        return sorted(dynamic_playlist, key=lambda n: random())
+
+    async def _get_dynamic_tracks(
+        self, media_item: Playlist, limit: int = 25
+    ) -> List[Track]:
+        """Get dynamic list of tracks for given item, fallback/default implementation."""
+        # TODO: query metadata provider(s) to get similar tracks (or tracks from similar artists)
+        raise UnsupportedFeaturedException(
+            "No Music Provider found that supports requesting similar tracks."
+        )
index 0d4034246dc4ed2bee468075934cd96c76733bd3..c476ae47195462f0d64e08caad25d34f074de03a 100644 (file)
@@ -11,7 +11,7 @@ from music_assistant.helpers.json import json_serializer
 from music_assistant.models.enums import EventType, MediaType, ProviderType
 from music_assistant.models.event import MassEvent
 from music_assistant.models.media_controller import MediaControllerBase
-from music_assistant.models.media_items import Radio
+from music_assistant.models.media_items import Radio, Track
 
 
 class RadioController(MediaControllerBase[Radio]):
@@ -124,3 +124,19 @@ class RadioController(MediaControllerBase[Radio]):
         )
         self.logger.debug("updated %s in database: %s", item.name, item_id)
         return await self.get_db_item(item_id)
+
+    async def _get_provider_dynamic_tracks(
+        self,
+        item_id: str,
+        provider: Optional[ProviderType] = None,
+        provider_id: Optional[str] = None,
+        limit: int = 25,
+    ) -> List[Track]:
+        """Generate a dynamic list of tracks based on the item's content."""
+        raise NotImplementedError("Dynamic tracks not supported for Radio MediaItem")
+
+    async def _get_dynamic_tracks(
+        self, media_item: Radio, limit: int = 25
+    ) -> List[Track]:
+        """Get dynamic list of tracks for given item, fallback/default implementation."""
+        raise NotImplementedError("Dynamic tracks not supported for Radio MediaItem")
index 1183378e1bf20caa37e47463a1e932554638dd56..11ddfb775fad85c5cd25a2d2e0f9b0bbdece6808 100644 (file)
@@ -17,7 +17,10 @@ from music_assistant.models.enums import (
     MusicProviderFeature,
     ProviderType,
 )
-from music_assistant.models.errors import MediaNotFoundError
+from music_assistant.models.errors import (
+    MediaNotFoundError,
+    UnsupportedFeaturedException,
+)
 from music_assistant.models.event import MassEvent
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import (
@@ -155,6 +158,35 @@ class TracksController(MediaControllerBase[Track]):
                     provider.name,
                 )
 
+    async def _get_provider_dynamic_tracks(
+        self,
+        item_id: str,
+        provider: Optional[ProviderType] = None,
+        provider_id: Optional[str] = None,
+        limit: int = 25,
+    ):
+        """Generate a dynamic list of tracks based on the track."""
+        prov = self.mass.music.get_provider(provider_id or provider)
+        if (
+            not prov
+            or MusicProviderFeature.SIMILAR_TRACKS not in prov.supported_features
+        ):
+            return []
+        # Grab similar tracks from the music provider
+        similar_tracks = await prov.get_similar_tracks(
+            prov_track_id=item_id, limit=limit
+        )
+        return similar_tracks
+
+    async def _get_dynamic_tracks(
+        self, media_item: Track, limit: int = 25
+    ) -> List[Track]:
+        """Get dynamic list of tracks for given item, fallback/default implementation."""
+        # TODO: query metadata provider(s) to get similar tracks (or tracks from similar artists)
+        raise UnsupportedFeaturedException(
+            "No Music Provider found that supports requesting similar tracks."
+        )
+
     async def add_db_item(self, item: Track, overwrite_existing: bool = False) -> Track:
         """Add a new item record to the database."""
         assert isinstance(item, Track), "Not a full Track object"
index db9d1a4acbc258b7f218546c66c75d8443f57c92..79cbb1e36f9c0d097605423a443a97cc7daa16da 100644 (file)
@@ -28,7 +28,7 @@ class Cache:
         """Async initialize of cache module."""
         self.__schedule_cleanup_task()
 
-    async def get(self, cache_key, checksum="", default=None):
+    async def get(self, cache_key: str, checksum: Optional[str] = None, default=None):
         """
         Get object from cache and return the results.
 
@@ -37,7 +37,7 @@ class Cache:
                     cacheobject matches the checkum provided
         """
         cur_time = int(time.time())
-        if not isinstance(checksum, str):
+        if checksum is not None and not isinstance(checksum, str):
             checksum = str(checksum)
 
         # try memory cache first
index feaa1eefbdc5e473ff7fb27092160921f46da928..f5233464a6c704d4aa09d60cdadaa8ce765ebb3d 100644 (file)
@@ -182,6 +182,7 @@ class QueueOption(Enum):
     REPLACE = "replace"
     NEXT = "next"
     ADD = "add"
+    RADIO = "radio"
 
 
 class CrossFadeMode(Enum):
@@ -267,6 +268,9 @@ class MusicProviderFeature(Enum):
     LIBRARY_TRACKS_EDIT = "library_tracks_edit"
     LIBRARY_PLAYLISTS_EDIT = "library_playlists_edit"
     LIBRARY_RADIOS_EDIT = "library_radios_edit"
+    # if we can grab 'similar tracks' from the music provider
+    # used to generate dynamic playlists
+    SIMILAR_TRACKS = "similar_tracks"
     # playlist-specific features
     PLAYLIST_TRACKS_EDIT = "playlist_tracks_edit"
     PLAYLIST_CREATE = "playlist_create"
index 04cd5c27371d0f2825642b8aa3957340f62f266c..3476c26b1eff80fc755be410d32cf2bbf4c59624 100644 (file)
@@ -35,3 +35,7 @@ class AudioError(MusicAssistantError):
 
 class QueueEmpty(MusicAssistantError):
     """Error raised when trying to start queue stream while queue is empty."""
+
+
+class UnsupportedFeaturedException(MusicAssistantError):
+    """Error raised when a feature is not supported."""
index 09a6a389e9d9b77601a2d5aaf252fe8b4cc87ebe..950feca7703f9735aa098068934729e594a4d9cf 100644 (file)
@@ -20,7 +20,7 @@ from music_assistant.models.errors import MediaNotFoundError
 from music_assistant.models.event import MassEvent
 
 from .enums import EventType, MediaType, MusicProviderFeature, ProviderType
-from .media_items import MediaItemType, PagedItems, media_from_dict
+from .media_items import MediaItemType, PagedItems, Track, media_from_dict
 
 if TYPE_CHECKING:
     from music_assistant.mass import MusicAssistant
@@ -451,3 +451,43 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
             MassEvent(EventType.MEDIA_ITEM_DELETED, db_item.uri, db_item)
         )
         self.logger.debug("deleted item with id %s from database", item_id)
+
+    async def dynamic_tracks(
+        self,
+        item_id: str,
+        provider: Optional[ProviderType] = None,
+        provider_id: Optional[str] = None,
+        limit: int = 25,
+    ) -> List[Track]:
+        """Return a dynamic list of tracks based on the given item."""
+        ref_item = await self.get(item_id, provider, provider_id)
+        for prov_id in ref_item.provider_ids:
+            prov = self.mass.music.get_provider(prov_id.prov_id)
+            if not prov.available:
+                continue
+            if MusicProviderFeature.SIMILAR_TRACKS not in prov.supported_features:
+                continue
+            return await self._get_provider_dynamic_tracks(
+                item_id=prov_id.item_id,
+                provider=prov_id.prov_type,
+                provider_id=prov_id.prov_id,
+                limit=limit,
+            )
+        # Fallback to the default implementation
+        return await self._get_dynamic_tracks(ref_item)
+
+    @abstractmethod
+    async def _get_provider_dynamic_tracks(
+        self,
+        item_id: str,
+        provider: Optional[ProviderType] = None,
+        provider_id: Optional[str] = None,
+        limit: int = 25,
+    ) -> List[Track]:
+        """Generate a dynamic list of tracks based on the item's content."""
+
+    @abstractmethod
+    async def _get_dynamic_tracks(
+        self, media_item: ItemCls, limit: int = 25
+    ) -> List[Track]:
+        """Get dynamic list of tracks for given item, fallback/default implementation."""
index 1cece3d927eb1de5a7468cc14579d95408598f12..61b863d08c2b45f399612ecbb57b7456061617bb 100644 (file)
@@ -228,6 +228,10 @@ class MusicProvider:
         """Create a new playlist on provider with given name."""
         raise NotImplementedError
 
+    async def get_similar_tracks(self, prov_track_id, limit=25) -> List[Track]:
+        """Retrieve a dynamic list of similar tracks based on the provided track."""
+        raise NotImplementedError
+
     async def get_stream_details(self, item_id: str) -> StreamDetails | None:
         """Get streamdetails for a track/radio."""
         raise NotImplementedError
index 0bb0639620d57fd9a433cf789e41fdbc04bb014e..8662d6675673b013ea1fab7768fff175cefd72b1 100644 (file)
@@ -57,6 +57,7 @@ class PlayerQueue:
         self._last_player_update: int = 0
         self._last_stream_id: str = ""
         self._snapshot: Optional[QueueSnapShot] = None
+        self._radio_source: List[MediaItemType] = []
         self.announcement_in_progress: bool = False
 
     async def setup(self) -> None:
@@ -185,15 +186,18 @@ class PlayerQueue:
                 QueueOption.REPLACE -> Replace queue contents with these items
                 QueueOption.NEXT -> Play item(s) after current playing item
                 QueueOption.ADD -> Append new items at end of the queue
+                QueueOption.RADIO -> Fill the queue contents with dynamic content based on the item(s)
             :param passive: if passive set to true the stream url will not be sent to the player.
         """
         if self.announcement_in_progress:
             self.logger.warning("Ignore queue command: An announcement is in progress")
             return
+
         # a single item or list of items may be provided
         if not isinstance(media, list):
             media = [media]
-        queue_items = []
+
+        tracks: List[MediaItemType] = []
         for item in media:
             # parse provided uri into a MA MediaItem or Basic QueueItem from URL
             if isinstance(item, str):
@@ -208,45 +212,50 @@ class PlayerQueue:
                 media_item = item
 
             # collect tracks to play
-            tracks = []
-            if media_item.media_type == MediaType.ARTIST:
-                tracks = await self.mass.music.artists.toptracks(
+            if queue_opt == QueueOption.RADIO:
+                # For dynamic/radio mode, the source items are stored and unpacked dynamically
+                tracks += [media_item]
+            elif media_item.media_type == MediaType.ARTIST:
+                tracks += await self.mass.music.artists.toptracks(
                     media_item.item_id, provider=media_item.provider
                 )
             elif media_item.media_type == MediaType.ALBUM:
-                tracks = await self.mass.music.albums.tracks(
+                tracks += await self.mass.music.albums.tracks(
                     media_item.item_id, provider=media_item.provider
                 )
             elif media_item.media_type == MediaType.PLAYLIST:
-                tracks = await self.mass.music.playlists.tracks(
+                tracks += await self.mass.music.playlists.tracks(
                     media_item.item_id, provider=media_item.provider
                 )
-            elif media_item.media_type in (
-                MediaType.RADIO,
-                MediaType.TRACK,
-            ):
-                # single item
-                tracks = [media_item]
+            else:
+                # single track or radio item
+                tracks += [media_item]
 
-            # only add available items
-            for track in tracks:
-                if not track.available:
-                    continue
-                queue_items.append(QueueItem.from_media_item(track))
+        # Handle Radio playback: clear queue and request first batch
+        if queue_opt == QueueOption.RADIO:
+            # clear existing items before we start radio
+            await self.clear()
+            # load the first batch
+            await self._load_radio_tracks(tracks)
+            if not passive:
+                await self.play_index(0)
+            return
+
+        # only add available items
+        queue_items = [QueueItem.from_media_item(x) for x in tracks if x.available]
 
         # clear queue first if it was finished
         if self._current_index and self._current_index >= (len(self._items) - 1):
             self._current_index = None
             self._items = []
 
-        # load items into the queue, make sure we have valid values
-        queue_items = [x for x in queue_items if isinstance(x, QueueItem)]
+        # if adding more than 50 items in play/next mode, treat as replace
+        if len(queue_items) > 50 and queue_opt in (QueueOption.PLAY, QueueOption.NEXT):
+            queue_opt = QueueOption.REPLACE
+
+        # load the items into the queue
         if queue_opt == QueueOption.REPLACE:
             await self.load(queue_items, passive)
-        elif (
-            queue_opt in [QueueOption.PLAY, QueueOption.NEXT] and len(queue_items) > 100
-        ):
-            await self.load(queue_items, passive)
         elif queue_opt == QueueOption.NEXT:
             await self.insert(queue_items, 1, passive)
         elif queue_opt == QueueOption.PLAY:
@@ -254,6 +263,29 @@ class PlayerQueue:
         elif queue_opt == QueueOption.ADD:
             await self.append(queue_items)
 
+    async def _load_radio_tracks(
+        self, radio_items: Optional[List[MediaItemType]] = None
+    ) -> None:
+        """Fill the Queue with (additional) Radio tracks."""
+        if radio_items:
+            self._radio_source = radio_items
+        assert self._radio_source, "No Radio item(s) loaded/active!"
+
+        tracks: List[MediaItemType] = []
+        # grab dynamic tracks for (all) source items
+        # shuffle the source items, just in case
+        for radio_item in random.sample(self._radio_source, len(self._radio_source)):
+            ctrl = self.mass.music.get_controller(radio_item.media_type)
+            tracks += await ctrl.dynamic_tracks(
+                item_id=radio_item.item_id, provider=radio_item.provider
+            )
+            # make sure we do not grab too much items
+            if len(tracks) >= 50:
+                break
+        # fill queue - filter out unavailable items
+        queue_items = [QueueItem.from_media_item(x) for x in tracks if x.available]
+        await self.append(queue_items)
+
     async def play_announcement(self, url: str, prepend_alert: bool = False) -> str:
         """
         Play given uri as Announcement on the queue.
@@ -523,6 +555,8 @@ class PlayerQueue:
 
     async def load(self, queue_items: List[QueueItem], passive: bool = False) -> None:
         """Load (overwrite) queue with new items."""
+        # reset radio source if a queue load is executed
+        self._radio_source = []
         for index, item in enumerate(queue_items):
             item.sort_index = index
         if self.settings.shuffle_enabled and len(queue_items) > 5:
@@ -598,6 +632,7 @@ class PlayerQueue:
 
     async def clear(self) -> None:
         """Clear all items in the queue."""
+        self._radio_source = []
         if self.player.state not in (PlayerState.IDLE, PlayerState.OFF):
             await self.stop()
         await self.update_items([])
@@ -650,15 +685,20 @@ class PlayerQueue:
         if self.player.active_queue != self or not self.active:
             return
 
-        new_index = self._current_index
         track_time = self._current_item_elapsed_time
         new_item_loaded = False
         if self.player.state == PlayerState.PLAYING and self.player.elapsed_time > 0:
             new_index, track_time = self.__get_queue_stream_index()
-        # process new index
-        if self._current_index != new_index:
-            # queue track updated
-            self._current_index = new_index
+
+            # process new index
+            if self._current_index != new_index:
+                # queue index updated
+                self._current_index = new_index
+                # watch dynamic radio items refill if needed
+                fill_index = len(self._items) - 5
+                if self._radio_source and (new_index >= fill_index):
+                    self.mass.create_task(self._load_radio_tracks())
+
         # check if a new track is loaded, wait for the streamdetails
         if (
             self.current_item
@@ -719,7 +759,8 @@ class PlayerQueue:
         # being higher than the number of items to detect end of queue and/or handle repeat.
         if cur_index is None:
             return 0
-        return cur_index + 1
+        next_index = cur_index + 1
+        return next_index
 
     def signal_update(self, items_changed: bool = False) -> None:
         """Signal state changed of this queue."""
@@ -758,6 +799,7 @@ class PlayerQueue:
         """Export object to dict."""
         cur_item = self.current_item.to_dict() if self.current_item else None
         next_item = self.next_item.to_dict() if self.next_item else None
+
         return {
             "queue_id": self.queue_id,
             "player": self.player.player_id,
@@ -772,6 +814,7 @@ class PlayerQueue:
             "next_item": next_item,
             "items": len(self._items),
             "settings": self.settings.to_dict(),
+            "radio_source": [x.to_dict() for x in self._radio_source[:5]],
         }
 
     async def update_items(self, queue_items: List[QueueItem]) -> None:
index 010f68fe16745abb12c63685ee1c8d3777a651d3..6fd4695a5efd99241d4f13adae3d169809dc4e12 100644 (file)
@@ -69,6 +69,7 @@ class SpotifyProvider(MusicProvider):
             MusicProviderFeature.SEARCH,
             MusicProviderFeature.ARTIST_ALBUMS,
             MusicProviderFeature.ARTIST_TOPTRACKS,
+            MusicProviderFeature.SIMILAR_TRACKS,
         )
 
     async def setup(self) -> bool:
@@ -290,6 +291,16 @@ class SpotifyProvider(MusicProvider):
             f"playlists/{prov_playlist_id}/tracks", data=data
         )
 
+    async def get_similar_tracks(self, prov_track_id, limit=25) -> List[Track]:
+        """Retrieve a dynamic list of tracks based on the provided item."""
+        endpoint = "recommendations"
+        items = await self._get_data(endpoint, seed_tracks=prov_track_id, limit=limit)
+        return [
+            await self._parse_track(item)
+            for item in items["tracks"]
+            if (item and item["id"])
+        ]
+
     async def get_stream_details(self, item_id: str) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
         # make sure a valid track is requested.
index b1bb0fec571cf7e22b9afda459e2ba821cf0a8b1..6352a0fc5dec2ab788d13e36d3ae2a294fabdc7f 100644 (file)
@@ -22,6 +22,8 @@ async def get_artist(prov_artist_id: str) -> Dict[str, str]:
         ytm = ytmusicapi.YTMusic()
         try:
             artist = ytm.get_artist(channelId=prov_artist_id)
+            # ChannelId can sometimes be different and original ID is not part of the response
+            artist["channelId"] = prov_artist_id
         except KeyError:
             user = ytm.get_user(channelId=prov_artist_id)
             artist = {"channelId": prov_artist_id, "name": user["name"]}
@@ -226,6 +228,31 @@ async def add_remove_playlist_tracks(
     return await loop.run_in_executor(None, _add_playlist_tracks)
 
 
+async def get_song_radio_tracks(
+    headers: Dict[str, str], username: str, prov_item_id: str, limit=25
+) -> Dict[str, str]:
+    """Async wrapper around the ytmusicapi radio function."""
+    user = username if is_brand_account(username) else None
+
+    def _get_song_radio_tracks():
+        ytm = ytmusicapi.YTMusic(auth=json.dumps(headers), user=user)
+        playlist_id = f"RDAMVM{prov_item_id}"
+        result = ytm.get_watch_playlist(
+            videoId=prov_item_id, playlistId=playlist_id, limit=limit
+        )
+        # Replace inconsistensies for easier parsing
+        for track in result["tracks"]:
+            if track.get("thumbnail"):
+                track["thumbnails"] = track["thumbnail"]
+                del track["thumbnail"]
+            if track.get("length"):
+                track["duration"] = get_sec(track["length"])
+        return result
+
+    loop = asyncio.get_running_loop()
+    return await loop.run_in_executor(None, _get_song_radio_tracks)
+
+
 async def search(query: str, ytm_filter: str = None, limit: int = 20) -> List[Dict]:
     """Async wrapper around the ytmusicapi search function."""
 
@@ -263,3 +290,13 @@ def get_playlist_checksum(playlist_obj: dict) -> str:
 def is_brand_account(username: str) -> bool:
     """Check if the provided username is a brand-account."""
     return len(username) == 21 and username.isdigit()
+
+
+def get_sec(time_str):
+    """Get seconds from time."""
+    parts = time_str.split(":")
+    if len(parts) == 3:
+        return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
+    if len(parts) == 2:
+        return int(parts[0]) * 60 + int(parts[1])
+    return 0
index 4ae52afce36c3ebc5fbd231d143a67255eb25a61..b6dceb98252a31266b18d7a8f2e6a793cca073ec 100644 (file)
@@ -42,6 +42,7 @@ from music_assistant.music_providers.ytmusic.helpers import (
     get_library_playlists,
     get_library_tracks,
     get_playlist,
+    get_song_radio_tracks,
     get_track,
     library_add_remove_album,
     library_add_remove_artist,
@@ -76,6 +77,7 @@ class YoutubeMusicProvider(MusicProvider):
             MusicProviderFeature.SEARCH,
             MusicProviderFeature.ARTIST_ALBUMS,
             MusicProviderFeature.ARTIST_TOPTRACKS,
+            MusicProviderFeature.SIMILAR_TRACKS,
         )
 
     async def setup(self) -> bool:
@@ -187,11 +189,15 @@ class YoutubeMusicProvider(MusicProvider):
     async def get_album_tracks(self, prov_album_id: str) -> List[Track]:
         """Get album tracks for given album id."""
         album_obj = await get_album(prov_album_id=prov_album_id)
-        return [
-            await self._parse_track(track)
-            for track in album_obj["tracks"]
-            if "tracks" in album_obj
-        ]
+        if not album_obj.get("tracks"):
+            return []
+        tracks = []
+        for idx, track_obj in enumerate(album_obj["tracks"], 1):
+            track = await self._parse_track(track_obj=track_obj)
+            track.disc_number = 0
+            track.track_number = idx
+            tracks.append(track)
+        return tracks
 
     async def get_artist(self, prov_artist_id) -> Artist:
         """Get full artist details by id."""
@@ -253,14 +259,14 @@ class YoutubeMusicProvider(MusicProvider):
         return []
 
     async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
-        """Get a list of 5 most popular tracks for the given artist."""
+        """Get a list of 25 most popular tracks for the given artist."""
         artist_obj = await get_artist(prov_artist_id=prov_artist_id)
-        if "songs" in artist_obj and "results" in artist_obj["songs"]:
-            return [
-                await self.get_track(track["videoId"])
-                for track in artist_obj["songs"]["results"]
-                if track.get("videoId")
-            ]
+        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=prov_playlist_id
+            )
+            return playlist_tracks[:25]
         return []
 
     async def library_add(self, prov_item_id, media_type: MediaType) -> None:
@@ -360,6 +366,31 @@ class YoutubeMusicProvider(MusicProvider):
             username=self.config.username,
         )
 
+    async def get_similar_tracks(self, prov_track_id, limit=25) -> List[Track]:
+        """Retrieve a dynamic list of tracks based on the provided item."""
+        result = []
+        result = await get_song_radio_tracks(
+            headers=self._headers,
+            username=self.config.username,
+            prov_item_id=prov_track_id,
+            limit=limit,
+        )
+        if "tracks" in result:
+            tracks = []
+            for track in result["tracks"]:
+                # Playlist tracks sometimes do not have a valid artist id
+                # In that case, call the API for track details based on track id
+                try:
+                    track = await self._parse_track(track)
+                    if track:
+                        tracks.append(track)
+                except InvalidDataError:
+                    track = await self.get_track(track["videoId"])
+                    if track:
+                        tracks.append(track)
+            return tracks
+        return []
+
     async def get_stream_details(self, item_id: str) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
         data = {
@@ -563,13 +594,13 @@ class YoutubeMusicProvider(MusicProvider):
             track.album = await self._parse_album(album, album["id"])
         if "isExplicit" in track_obj:
             track.metadata.explicit = track_obj["isExplicit"]
-        if "duration" in track_obj and track_obj["duration"].isdigit():
-            track.duration = track_obj["duration"]
+        if "duration" in track_obj and str(track_obj["duration"]).isdigit():
+            track.duration = int(track_obj["duration"])
         elif (
             "duration_seconds" in track_obj
             and str(track_obj["duration_seconds"]).isdigit()
         ):
-            track.duration = track_obj["duration_seconds"]
+            track.duration = int(track_obj["duration_seconds"])
         available = True
         if "isAvailable" in track_obj:
             available = track_obj["isAvailable"]