Skip to content

Commit 6b42fca

Browse files
committed
Merge branch 'dev' of https://github.com/music-assistant/server into dev
2 parents 0fc7fdc + dc5bbee commit 6b42fca

File tree

3 files changed

+159
-17
lines changed

3 files changed

+159
-17
lines changed

music_assistant/providers/plex/__init__.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
MediaItemType,
4141
Playlist,
4242
ProviderMapping,
43+
RecommendationFolder,
4344
SearchResults,
4445
Track,
4546
UniqueList,
@@ -91,6 +92,7 @@
9192
CONF_PLEX_LIKE_RATING = "plex_like_rating"
9293
CONF_PLEX_FAVORITE_THRESHOLD = "plex_favorite_threshold"
9394
CONF_PLEX_UNLIKE_RATING = "plex_unlike_rating"
95+
CONF_HUB_ITEMS_LIMIT = "hub_items_limit"
9496

9597
FAKE_ARTIST_PREFIX = "_fake://"
9698

@@ -108,6 +110,7 @@
108110
ProviderFeature.ARTIST_ALBUMS,
109111
ProviderFeature.ARTIST_TOPTRACKS,
110112
ProviderFeature.SIMILAR_TRACKS,
113+
ProviderFeature.RECOMMENDATIONS,
111114
}
112115

113116

@@ -388,6 +391,19 @@ async def get_config_entries( # noqa: PLR0915
388391
)
389392
)
390393

394+
# Recommendation settings (advanced)
395+
entries.append(
396+
ConfigEntry(
397+
key=CONF_HUB_ITEMS_LIMIT,
398+
type=ConfigEntryType.INTEGER,
399+
label="Items per hub",
400+
description="Maximum number of items to load from each hub (default: 10)",
401+
default_value=10,
402+
category="advanced",
403+
range=(1, 100),
404+
)
405+
)
406+
391407
# return all config entries
392408
return tuple(entries)
393409

@@ -1125,6 +1141,111 @@ async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[
11251141
self.logger.warning("Error getting similar tracks for %s: %s", prov_track_id, err)
11261142
return []
11271143

1144+
@use_cache(3600 * 3, cache_checksum="v2") # Cache for 3 hours
1145+
async def recommendations(self) -> list[RecommendationFolder]:
1146+
"""Get recommendations from Plex hubs."""
1147+
try:
1148+
# Get the configured limit for items per hub
1149+
limit_value = self.config.get_value(CONF_HUB_ITEMS_LIMIT)
1150+
limit = int(limit_value) if isinstance(limit_value, (int, float, str)) else 10
1151+
1152+
# Fetch hubs from the music library section with count parameter
1153+
# The section's hubs() method uses /hubs/sections/{key}?includeStations=1
1154+
# We need to add the count parameter manually to limit items per hub
1155+
key = f"/hubs/sections/{self._plex_library.key}?includeStations=1&count={limit}"
1156+
hubs = await self._run_async(self._plex_library.fetchItems, key)
1157+
1158+
if not hubs:
1159+
self.logger.debug("No hubs available from Plex")
1160+
return []
1161+
1162+
self.logger.debug(
1163+
"Fetching %d hubs (limit: %d items per hub)",
1164+
len(hubs),
1165+
limit,
1166+
)
1167+
1168+
folders = []
1169+
for hub in hubs:
1170+
# Create a recommendation folder for each hub
1171+
folder = RecommendationFolder(
1172+
name=hub.title,
1173+
item_id=f"{self.instance_id}_{hub.hubIdentifier}",
1174+
provider=self.lookup_key,
1175+
icon="mdi-music",
1176+
)
1177+
1178+
# Parse each item based on its type (limit to configured max)
1179+
# Use _partialItems to respect the count limit from the hubs() call
1180+
# rather than hub.items() which fetches ALL items if more is True
1181+
# _partialItems is a cached property that's already loaded, so no need for async
1182+
hub_items = hub._partialItems
1183+
self.logger.debug(
1184+
"Processing hub '%s' (%s) with %d partial items",
1185+
hub.title,
1186+
hub.hubIdentifier,
1187+
len(hub_items),
1188+
)
1189+
for item in hub_items:
1190+
try:
1191+
# Skip items without type attribute
1192+
if not hasattr(item, "type"):
1193+
self.logger.debug(
1194+
"Skipping item in hub '%s': no type attribute",
1195+
hub.title,
1196+
)
1197+
continue
1198+
1199+
# Parse item based on its type
1200+
if item.type == "track":
1201+
folder.items.append(await self._parse_track(item))
1202+
elif item.type == "album":
1203+
folder.items.append(await self._parse_album(item))
1204+
elif item.type == "artist":
1205+
folder.items.append(await self._parse_artist(item))
1206+
elif item.type == "playlist":
1207+
folder.items.append(await self._parse_playlist(item))
1208+
# Try to parse other types generically
1209+
elif parsed_item := await self._parse(item):
1210+
folder.items.append(parsed_item) # type: ignore[arg-type]
1211+
else:
1212+
self.logger.debug(
1213+
"Skipping unsupported item type '%s' in hub '%s'",
1214+
item.type,
1215+
hub.title,
1216+
)
1217+
except Exception as err:
1218+
self.logger.debug(
1219+
"Failed to parse item (type: %s) in hub '%s': %s",
1220+
getattr(item, "type", "unknown"),
1221+
hub.title,
1222+
str(err),
1223+
)
1224+
continue
1225+
1226+
# Only add folder if it has items
1227+
if folder.items:
1228+
folders.append(folder)
1229+
self.logger.debug(
1230+
"Added hub '%s' (%s) with %d items",
1231+
hub.title,
1232+
hub.hubIdentifier,
1233+
len(folder.items),
1234+
)
1235+
else:
1236+
self.logger.debug(
1237+
"Skipping hub '%s' (%s): no items after parsing",
1238+
hub.title,
1239+
hub.hubIdentifier,
1240+
)
1241+
1242+
self.logger.debug("Retrieved %d recommendation folders from Plex", len(folders))
1243+
return folders
1244+
1245+
except Exception as err:
1246+
self.logger.warning("Error getting recommendations from Plex: %s", err)
1247+
return []
1248+
11281249
async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
11291250
"""Get streamdetails for a track."""
11301251
plex_track = await self._get_data(item_id, PlexTrack)

music_assistant/providers/qobuz/__init__.py

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
ContentType,
1717
ExternalID,
1818
ImageType,
19+
MediaType,
1920
ProviderFeature,
2021
StreamType,
2122
)
@@ -31,7 +32,6 @@
3132
AudioFormat,
3233
MediaItemImage,
3334
MediaItemType,
34-
MediaType,
3535
Playlist,
3636
ProviderMapping,
3737
SearchResults,
@@ -46,7 +46,7 @@
4646
VARIOUS_ARTISTS_NAME,
4747
)
4848
from music_assistant.controllers.cache import use_cache
49-
from music_assistant.helpers.app_vars import app_var
49+
from music_assistant.helpers.app_vars import app_var # type: ignore[attr-defined]
5050
from music_assistant.helpers.json import json_loads
5151
from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries
5252
from music_assistant.helpers.util import (
@@ -77,6 +77,7 @@
7777
ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
7878
ProviderFeature.LIBRARY_TRACKS_EDIT,
7979
ProviderFeature.PLAYLIST_TRACKS_EDIT,
80+
ProviderFeature.PLAYLIST_CREATE,
8081
ProviderFeature.BROWSE,
8182
ProviderFeature.SEARCH,
8283
ProviderFeature.ARTIST_ALBUMS,
@@ -227,7 +228,7 @@ async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
227228
yield self._parse_playlist(item)
228229

229230
@use_cache(3600 * 24 * 30) # Cache for 30 days
230-
async def get_artist(self, prov_artist_id) -> Artist:
231+
async def get_artist(self, prov_artist_id: str) -> Artist:
231232
"""Get full artist details by id."""
232233
params = {"artist_id": prov_artist_id}
233234
if (artist_obj := await self._get_data("artist/get", **params)) and artist_obj["id"]:
@@ -236,7 +237,7 @@ async def get_artist(self, prov_artist_id) -> Artist:
236237
raise MediaNotFoundError(msg)
237238

238239
@use_cache(3600 * 24 * 30) # Cache for 30 days
239-
async def get_album(self, prov_album_id) -> Album:
240+
async def get_album(self, prov_album_id: str) -> Album:
240241
"""Get full album details by id."""
241242
params = {"album_id": prov_album_id}
242243
if (album_obj := await self._get_data("album/get", **params)) and album_obj["id"]:
@@ -245,7 +246,7 @@ async def get_album(self, prov_album_id) -> Album:
245246
raise MediaNotFoundError(msg)
246247

247248
@use_cache(3600 * 24 * 30) # Cache for 30 days
248-
async def get_track(self, prov_track_id) -> Track:
249+
async def get_track(self, prov_track_id: str) -> Track:
249250
"""Get full track details by id."""
250251
params = {"track_id": prov_track_id}
251252
if (track_obj := await self._get_data("track/get", **params)) and track_obj["id"]:
@@ -254,16 +255,30 @@ async def get_track(self, prov_track_id) -> Track:
254255
raise MediaNotFoundError(msg)
255256

256257
@use_cache(3600 * 24 * 30) # Cache for 30 days
257-
async def get_playlist(self, prov_playlist_id) -> Playlist:
258+
async def get_playlist(self, prov_playlist_id: str) -> Playlist:
258259
"""Get full playlist details by id."""
259260
params = {"playlist_id": prov_playlist_id}
260261
if (playlist_obj := await self._get_data("playlist/get", **params)) and playlist_obj["id"]:
261262
return self._parse_playlist(playlist_obj)
262263
msg = f"Item {prov_playlist_id} not found"
263264
raise MediaNotFoundError(msg)
264265

266+
async def create_playlist(self, name: str) -> Playlist:
267+
"""Create a new playlist on Qobuz with the given name."""
268+
playlist_obj = await self._get_data(
269+
"playlist/create",
270+
name=name,
271+
description="",
272+
is_public=0,
273+
is_collaborative=0,
274+
)
275+
if not playlist_obj or not playlist_obj.get("id"):
276+
msg = f"Failed to create playlist: {name}"
277+
raise InvalidDataError(msg)
278+
return self._parse_playlist(playlist_obj)
279+
265280
@use_cache(3600 * 24 * 30) # Cache for 30 days
266-
async def get_album_tracks(self, prov_album_id) -> list[Track]:
281+
async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
267282
"""Get all album tracks for given album id."""
268283
params = {"album_id": prov_album_id}
269284
return [
@@ -295,7 +310,7 @@ async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> lis
295310
return result
296311

297312
@use_cache(3600 * 24 * 14) # Cache for 14 days
298-
async def get_artist_albums(self, prov_artist_id) -> list[Album]:
313+
async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
299314
"""Get a list of albums for the given artist."""
300315
result = await self._get_data(
301316
"artist/get",
@@ -311,7 +326,7 @@ async def get_artist_albums(self, prov_artist_id) -> list[Album]:
311326
]
312327

313328
@use_cache(3600 * 24 * 14) # Cache for 14 days
314-
async def get_artist_toptracks(self, prov_artist_id) -> list[Track]:
329+
async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
315330
"""Get a list of most popular tracks for the given artist."""
316331
result = await self._get_data(
317332
"artist/get",
@@ -387,7 +402,7 @@ async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[
387402

388403
async def remove_playlist_tracks(
389404
self, prov_playlist_id: str, positions_to_remove: tuple[int]
390-
) -> None:
405+
) -> Any:
391406
"""Remove track(s) from playlist."""
392407
playlist_track_ids = set()
393408
for pos in positions_to_remove:
@@ -497,7 +512,7 @@ async def on_streamed(
497512
duration=try_parse_int(streamdetails.seconds_streamed),
498513
)
499514

500-
def _parse_artist(self, artist_obj: dict):
515+
def _parse_artist(self, artist_obj: dict) -> Artist:
501516
"""Parse qobuz artist object to generic layout."""
502517
artist = Artist(
503518
item_id=str(artist_obj["id"]),
@@ -686,7 +701,7 @@ async def _parse_track(self, track_obj: dict) -> Track:
686701

687702
return track
688703

689-
def _parse_playlist(self, playlist_obj):
704+
def _parse_playlist(self, playlist_obj: str) -> Playlist:
690705
"""Parse qobuz playlist object to generic layout."""
691706
is_editable = (
692707
playlist_obj["owner"]["id"] == self._user_auth_info["user"]["id"]
@@ -719,7 +734,7 @@ def _parse_playlist(self, playlist_obj):
719734
return playlist
720735

721736
@lock
722-
async def _auth_token(self):
737+
async def _auth_token(self) -> None:
723738
"""Login to qobuz and store the token."""
724739
if self._user_auth_info:
725740
return self._user_auth_info["user_auth_token"]
@@ -738,7 +753,7 @@ async def _auth_token(self):
738753
return details["user_auth_token"]
739754
return None
740755

741-
async def _get_all_items(self, endpoint, key="tracks", **kwargs):
756+
async def _get_all_items(self, endpoint, key="tracks", **kwargs) -> list[dict]:
742757
"""Get all items from a paged list."""
743758
limit = 50
744759
offset = 0
@@ -759,7 +774,9 @@ async def _get_all_items(self, endpoint, key="tracks", **kwargs):
759774
return all_items
760775

761776
@throttle_with_retries
762-
async def _get_data(self, endpoint, sign_request=False, **kwargs):
777+
async def _get_data(
778+
self, endpoint: str, sign_request: bool = False, **kwargs: Any
779+
) -> dict | None:
763780
"""Get data from api."""
764781
self.logger.debug("Handling GET request to %s", endpoint)
765782
url = f"http://www.qobuz.com/api.json/0.2/{endpoint}"
@@ -808,7 +825,12 @@ async def _get_data(self, endpoint, sign_request=False, **kwargs):
808825
raise InvalidDataError(msg)
809826

810827
@throttle_with_retries
811-
async def _post_data(self, endpoint, params=None, data=None):
828+
async def _post_data(
829+
self,
830+
endpoint: str,
831+
params: dict[str, Any] | None = None,
832+
data: dict[str, Any] | None = None,
833+
) -> dict[str, Any]:
812834
"""Post data to api."""
813835
self.logger.debug("Handling POST request to %s", endpoint)
814836
if not params:

music_assistant/providers/squeezelite/player.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ def __init__(
9191
PlayerFeature.MULTI_DEVICE_DSP,
9292
PlayerFeature.VOLUME_SET,
9393
PlayerFeature.PAUSE,
94-
PlayerFeature.VOLUME_MUTE,
9594
PlayerFeature.ENQUEUE,
9695
PlayerFeature.GAPLESS_PLAYBACK,
9796
PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE,

0 commit comments

Comments
 (0)