Skip to content

Commit ce63371

Browse files
authored
catch up with dev (#2622)
2 parents 5d9e0b5 + ad6eb43 commit ce63371

File tree

9 files changed

+212
-100
lines changed

9 files changed

+212
-100
lines changed

.github/workflows/release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ on:
3434
env:
3535
PYTHON_VERSION: "3.12"
3636
BASE_IMAGE_VERSION_STABLE: "1.3.1"
37-
BASE_IMAGE_VERSION_BETA: "1.4.5"
38-
BASE_IMAGE_VERSION_NIGHTLY: "1.4.5"
37+
BASE_IMAGE_VERSION_BETA: "1.4.8"
38+
BASE_IMAGE_VERSION_NIGHTLY: "1.4.8"
3939

4040
jobs:
4141
preflight-checks:

music_assistant/__main__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,19 @@ def setup_logger(data_path: str, level: str = "DEBUG") -> logging.Logger:
133133
logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
134134
logging.getLogger("numba").setLevel(logging.WARNING)
135135

136+
# Add a filter to suppress slow callback warnings from buffered audio streaming
137+
# These warnings are expected when audio buffers fill up and producers wait for consumers
138+
class BufferedGeneratorFilter(logging.Filter):
139+
"""Filter out expected slow callback warnings from buffered audio generators."""
140+
141+
def filter(self, record: logging.LogRecord) -> bool:
142+
"""Return False to suppress the log record."""
143+
return not (
144+
record.levelno == logging.WARNING and "buffered.<locals>.producer()" in record.msg
145+
)
146+
147+
logging.getLogger("asyncio").addFilter(BufferedGeneratorFilter())
148+
136149
sys.excepthook = lambda *args: logging.getLogger(None).exception(
137150
"Uncaught exception",
138151
exc_info=args,

music_assistant/controllers/streams.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -877,8 +877,15 @@ def get_stream(
877877
# because this could have been a group
878878
player_id=media.custom_data["player_id"],
879879
)
880-
elif media.source_id and media.source_id.startswith(UGP_PREFIX):
881-
# special case: UGP stream
880+
elif (
881+
media.media_type == MediaType.FLOW_STREAM
882+
and media.source_id
883+
and media.source_id.startswith(UGP_PREFIX)
884+
and media.uri
885+
and "/ugp/" in media.uri
886+
):
887+
# special case: member player accessing UGP stream
888+
# Check URI to distinguish from the UGP accessing its own stream
882889
ugp_player = cast("UniversalGroupPlayer", self.mass.players.get(media.source_id))
883890
ugp_stream = ugp_player.stream
884891
assert ugp_stream is not None # for type checker

music_assistant/providers/apple_music/__init__.py

Lines changed: 104 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107

108108
CONF_MUSIC_APP_TOKEN = "music_app_token"
109109
CONF_MUSIC_USER_TOKEN = "music_user_token"
110+
CONF_MUSIC_USER_MANUAL_TOKEN = "music_user_manual_token"
110111
CONF_MUSIC_USER_TOKEN_TIMESTAMP = "music_user_token_timestamp"
111112
CACHE_CATEGORY_DECRYPT_KEY = 1
112113

@@ -236,7 +237,7 @@ async def serve_mk_glue(request: web.Request) -> web.Response:
236237
key=CONF_MUSIC_USER_TOKEN,
237238
type=ConfigEntryType.SECURE_STRING,
238239
label="Music User Token",
239-
required=True,
240+
required=False,
240241
action="CONF_ACTION_AUTH",
241242
description="Authenticate with Apple Music to retrieve a valid music user token.",
242243
action_label="Authenticate with Apple Music",
@@ -250,6 +251,19 @@ async def serve_mk_glue(request: web.Request) -> web.Response:
250251
)
251252
else None,
252253
),
254+
ConfigEntry(
255+
key=CONF_MUSIC_USER_MANUAL_TOKEN,
256+
type=ConfigEntryType.SECURE_STRING,
257+
label="Manual Music User Token",
258+
required=False,
259+
category="advanced",
260+
description=(
261+
"Authenticate with a manual Music User Token in case the Authentication flow"
262+
" is unsupported (e.g. when using child accounts)."
263+
),
264+
help_link="https://www.music-assistant.io/music-providers/apple-music/",
265+
value=values.get(CONF_MUSIC_USER_MANUAL_TOKEN),
266+
),
253267
ConfigEntry(
254268
key=CONF_MUSIC_USER_TOKEN_TIMESTAMP,
255269
type=ConfigEntryType.INTEGER,
@@ -278,7 +292,9 @@ class AppleMusicProvider(MusicProvider):
278292

279293
async def handle_async_init(self) -> None:
280294
"""Handle async initialization of the provider."""
281-
self._music_user_token = self.config.get_value(CONF_MUSIC_USER_TOKEN)
295+
self._music_user_token = self.config.get_value(
296+
CONF_MUSIC_USER_MANUAL_TOKEN
297+
) or self.config.get_value(CONF_MUSIC_USER_TOKEN)
282298
self._music_app_token = self.config.get_value(CONF_MUSIC_APP_TOKEN)
283299
self._storefront = await self._get_user_storefront()
284300
# create random session id to use for decryption keys
@@ -349,11 +365,29 @@ async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
349365
async def get_library_albums(self) -> AsyncGenerator[Album, None]:
350366
"""Retrieve library albums from the provider."""
351367
endpoint = "me/library/albums"
352-
for item in await self._get_all_items(
368+
album_items = await self._get_all_items(
353369
endpoint, include="catalog,artists", extend="editorialNotes"
354-
):
370+
)
371+
album_catalog_item_ids = [
372+
item["id"]
373+
for item in album_items
374+
if item and item["id"] and not self.is_library_id(item["id"])
375+
]
376+
album_library_item_ids = [
377+
item["id"]
378+
for item in album_items
379+
if item and item["id"] and self.is_library_id(item["id"])
380+
]
381+
rating_catalog_response = await self._get_ratings(album_catalog_item_ids, MediaType.ALBUM)
382+
rating_library_response = await self._get_ratings(album_library_item_ids, MediaType.ALBUM)
383+
for item in album_items:
355384
if item and item["id"]:
356-
album = self._parse_album(item)
385+
is_favourite = (
386+
rating_catalog_response.get(item["id"])
387+
if not self.is_library_id(item["id"])
388+
else rating_library_response.get(item["id"])
389+
)
390+
album = self._parse_album(item, is_favourite)
357391
if album:
358392
yield album
359393

@@ -384,18 +418,33 @@ async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
384418
track = self._parse_track(item, is_favourite)
385419
yield track
386420
# Yield library-only tracks using their library metadata
421+
library_ids = [item["id"] for item in library_only_tracks if item and item["id"]]
422+
library_rating_response = await self._get_ratings(library_ids, MediaType.TRACK)
387423
for item in library_only_tracks:
388-
yield self._parse_track(item)
424+
is_favourite = library_rating_response.get(item["id"])
425+
yield self._parse_track(item, is_favourite)
389426

390427
async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
391428
"""Retrieve playlists from the provider."""
392429
endpoint = "me/library/playlists"
393-
for item in await self._get_all_items(endpoint):
430+
playlist_items = await self._get_all_items(endpoint)
431+
playlist_library_item_ids = [
432+
item["id"]
433+
for item in playlist_items
434+
if item and item["id"] and self.is_library_id(item["id"])
435+
]
436+
rating_library_response = await self._get_ratings(
437+
playlist_library_item_ids, MediaType.PLAYLIST
438+
)
439+
for item in playlist_items:
440+
is_favourite = rating_library_response.get(item["id"])
394441
# Prefer catalog information over library information in case of public playlists
395442
if item["attributes"]["hasCatalog"]:
396-
yield await self.get_playlist(item["attributes"]["playParams"]["globalId"])
443+
yield await self.get_playlist(
444+
item["attributes"]["playParams"]["globalId"], is_favourite
445+
)
397446
elif item and item["id"]:
398-
yield self._parse_playlist(item)
447+
yield self._parse_playlist(item, is_favourite)
399448

400449
@use_cache()
401450
async def get_artist(self, prov_artist_id) -> Artist:
@@ -409,7 +458,9 @@ async def get_album(self, prov_album_id) -> Album:
409458
"""Get full album details by id."""
410459
endpoint = f"catalog/{self._storefront}/albums/{prov_album_id}"
411460
response = await self._get_data(endpoint, include="artists")
412-
return self._parse_album(response["data"][0])
461+
rating_response = await self._get_ratings([prov_album_id], MediaType.ALBUM)
462+
is_favourite = rating_response.get(prov_album_id)
463+
return self._parse_album(response["data"][0], is_favourite)
413464

414465
@use_cache()
415466
async def get_track(self, prov_track_id) -> Track:
@@ -421,15 +472,15 @@ async def get_track(self, prov_track_id) -> Track:
421472
return self._parse_track(response["data"][0], is_favourite)
422473

423474
@use_cache()
424-
async def get_playlist(self, prov_playlist_id) -> Playlist:
475+
async def get_playlist(self, prov_playlist_id, is_favourite: bool = False) -> Playlist:
425476
"""Get full playlist details by id."""
426-
if self._is_catalog_id(prov_playlist_id):
477+
if not self.is_library_id(prov_playlist_id):
427478
endpoint = f"catalog/{self._storefront}/playlists/{prov_playlist_id}"
428479
else:
429480
endpoint = f"me/library/playlists/{prov_playlist_id}"
430481
endpoint = f"catalog/{self._storefront}/playlists/{prov_playlist_id}"
431482
response = await self._get_data(endpoint)
432-
return self._parse_playlist(response["data"][0])
483+
return self._parse_playlist(response["data"][0], is_favourite)
433484

434485
@use_cache()
435486
async def get_album_tracks(self, prov_album_id) -> list[Track]:
@@ -464,9 +515,12 @@ async def get_playlist_tracks(self, prov_playlist_id, page: int = 0) -> list[Tra
464515
)
465516
if not response or "data" not in response:
466517
return result
518+
playlist_track_ids = [track["id"] for track in response["data"] if track and track["id"]]
519+
rating_response = await self._get_ratings(playlist_track_ids, MediaType.TRACK)
467520
for index, track in enumerate(response["data"]):
468521
if track and track["id"]:
469-
parsed_track = self._parse_track(track)
522+
is_favourite = rating_response.get(track["id"])
523+
parsed_track = self._parse_track(track, is_favourite)
470524
parsed_track.position = offset + index + 1
471525
result.append(parsed_track)
472526
return result
@@ -481,7 +535,17 @@ async def get_artist_albums(self, prov_artist_id) -> list[Album]:
481535
# Some artists do not have albums, return empty list
482536
self.logger.info("No albums found for artist %s", prov_artist_id)
483537
return []
484-
return [self._parse_album(album) for album in response if album["id"]]
538+
album_ids = [album["id"] for album in response if album["id"]]
539+
rating_response = await self._get_ratings(album_ids, MediaType.ALBUM)
540+
albums = []
541+
for album in response:
542+
if not album["id"]:
543+
continue
544+
is_favourite = rating_response.get(album["id"])
545+
parsed_album = self._parse_album(album, is_favourite)
546+
if parsed_album:
547+
albums.append(parsed_album)
548+
return albums
485549

486550
@use_cache(3600 * 24 * 7) # cache for 7 days
487551
async def get_artist_toptracks(self, prov_artist_id) -> list[Track]:
@@ -493,7 +557,15 @@ async def get_artist_toptracks(self, prov_artist_id) -> list[Track]:
493557
# Some artists do not have top tracks, return empty list
494558
self.logger.info("No top tracks found for artist %s", prov_artist_id)
495559
return []
496-
return [self._parse_track(track) for track in response["data"] if track["id"]]
560+
track_ids = [track["id"] for track in response["data"] if track["id"]]
561+
rating_response = await self._get_ratings(track_ids, MediaType.TRACK)
562+
tracks = []
563+
for track in response["data"]:
564+
if not track["id"]:
565+
continue
566+
is_favourite = rating_response.get(track["id"])
567+
tracks.append(self._parse_track(track, is_favourite))
568+
return tracks
497569

498570
async def library_add(self, item: MediaItemType) -> None:
499571
"""Add item to library."""
@@ -548,9 +620,12 @@ async def get_similar_tracks(self, prov_track_id, limit=25) -> list[Track]:
548620
response = await self._post_data(endpoint, include="artists")
549621
if not response or "data" not in response:
550622
break
623+
track_ids = [track["id"] for track in response["data"] if track and track["id"]]
624+
rating_response = await self._get_ratings(track_ids, MediaType.TRACK)
551625
for track in response["data"]:
552626
if track and track["id"]:
553-
found_tracks.append(self._parse_track(track))
627+
is_favourite = rating_response.get(track["id"])
628+
found_tracks.append(self._parse_track(track, is_favourite))
554629
return found_tracks
555630

556631
async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
@@ -655,7 +730,9 @@ def _parse_artist(self, artist_obj: dict[str, Any]) -> Artist:
655730
artist.metadata.description = notes.get("standard") or notes.get("short")
656731
return artist
657732

658-
def _parse_album(self, album_obj: dict) -> Album | ItemMapping | None:
733+
def _parse_album(
734+
self, album_obj: dict, is_favourite: bool | None = None
735+
) -> Album | ItemMapping | None:
659736
"""Parse album object to generic layout."""
660737
relationships = album_obj.get("relationships", {})
661738
response_type = album_obj.get("type")
@@ -747,7 +824,7 @@ def _parse_album(self, album_obj: dict) -> Album | ItemMapping | None:
747824
inferred_type = infer_album_type(album.name, "")
748825
if inferred_type in (AlbumType.SOUNDTRACK, AlbumType.LIVE):
749826
album.album_type = inferred_type
750-
827+
album.favorite = is_favourite or False
751828
return album
752829

753830
def _parse_track(
@@ -827,7 +904,9 @@ def _parse_track(
827904
track.favorite = is_favourite or False
828905
return track
829906

830-
def _parse_playlist(self, playlist_obj: dict[str, Any]) -> Playlist:
907+
def _parse_playlist(
908+
self, playlist_obj: dict[str, Any], is_favourite: bool | None = None
909+
) -> Playlist:
831910
"""Parse Apple Music playlist object to generic layout."""
832911
attributes = playlist_obj["attributes"]
833912
playlist_id = attributes["playParams"].get("globalId") or playlist_obj["id"]
@@ -861,6 +940,7 @@ def _parse_playlist(self, playlist_obj: dict[str, Any]) -> Playlist:
861940
)
862941
if description := attributes.get("description"):
863942
playlist.metadata.description = description.get("standard")
943+
playlist.favorite = is_favourite or False
864944
return playlist
865945

866946
async def _get_all_items(self, endpoint, key="data", **kwargs) -> list[dict]:
@@ -994,7 +1074,10 @@ async def _get_ratings(self, item_ids: list[str], media_type: MediaType) -> dict
9941074
raise NotImplementedError(
9951075
"Ratings are not available for artist in the Apple Music API."
9961076
)
997-
endpoint = self._translate_media_type_to_apple_type(media_type)
1077+
if len(item_ids) == 0:
1078+
return {}
1079+
apple_type = self._translate_media_type_to_apple_type(media_type)
1080+
endpoint = apple_type if not self.is_library_id(item_ids[0]) else f"library-{apple_type}"
9981081
# Apple Music limits to 200 ids per request
9991082
max_ids_per_request = 200
10001083
results = {}

music_assistant/providers/builtin_player/player.py

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,6 @@ async def _serve_audio_stream(self, request: web.Request) -> web.StreamResponse:
227227
"""Serve the flow stream audio to a player."""
228228
player_id = request.path.rsplit(".")[0].rsplit("/")[-1]
229229
format_str = request.path.rsplit(".")[-1]
230-
# bitrate = request.query.get("bitrate")
231-
queue = self.mass.player_queues.get(player_id)
232230
self.logger.debug("Serving audio stream to %s", player_id)
233231

234232
if not (player := self.mass.players.get(player_id)):
@@ -256,16 +254,8 @@ async def _serve_audio_stream(self, request: web.Request) -> web.StreamResponse:
256254
# on iOS devices with Home Assistant OS installations.
257255

258256
media = player._current_media
259-
if queue is None or media is None:
260-
raise web.HTTPNotFound(reason="No active queue or media found!")
261-
262-
if media.source_id is None:
263-
raise web.HTTPError # TODO: better error
264-
265-
queue_item = self.mass.player_queues.get_item(media.source_id, media.queue_item_id)
266-
267-
if queue_item is None:
268-
raise web.HTTPError # TODO: better error
257+
if media is None:
258+
raise web.HTTPNotFound(reason="No active media found!")
269259

270260
# TODO: set encoding quality using a bitrate parameter,
271261
# maybe even dynamic with auto/semiauto switching with bad network?
@@ -280,12 +270,10 @@ async def _serve_audio_stream(self, request: web.Request) -> web.StreamResponse:
280270
bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
281271
channels=INTERNAL_PCM_FORMAT.channels,
282272
)
273+
283274
async for chunk in get_ffmpeg_stream(
284-
audio_input=self.mass.streams.get_queue_flow_stream(
285-
queue=queue,
286-
start_queue_item=queue_item,
287-
pcm_format=pcm_format,
288-
),
275+
# Use get_stream helper which handles all media types including UGP streams
276+
audio_input=self.mass.streams.get_stream(media, pcm_format),
289277
input_format=pcm_format,
290278
output_format=stream_format,
291279
# Apple ignores "Accept-Ranges=none" on iOS and iPadOS for some reason,

music_assistant/providers/musicbrainz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ async def get_artist_details_by_resource_url(
430430
@throttle_with_retries
431431
async def get_data(self, endpoint: str, **kwargs: str) -> Any:
432432
"""Get data from api."""
433-
url = f"http://musicbrainz.org/ws/2/{endpoint}"
433+
url = f"https://musicbrainz.org/ws/2/{endpoint}"
434434
headers = {
435435
"User-Agent": f"Music Assistant/{self.mass.version} (https://music-assistant.io)"
436436
}

0 commit comments

Comments
 (0)