Skip to content

Commit 13eee9e

Browse files
authored
Fix add item to favorites feature (#83)
1 parent 0536c2b commit 13eee9e

File tree

3 files changed

+144
-36
lines changed

3 files changed

+144
-36
lines changed

music_assistant_client/music.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import TYPE_CHECKING, cast
77

88
from music_assistant_models.enums import AlbumType, ImageType, MediaType
9+
from music_assistant_models.helpers import create_sort_name
910
from music_assistant_models.media_items import (
1011
Album,
1112
Artist,
@@ -675,6 +676,82 @@ async def mark_item_unplayed(
675676
"""Mark item as unplayed in playlog."""
676677
await self.client.send_command("music/mark_unplayed", media_item=media_item)
677678

679+
async def get_track_by_name(
680+
self,
681+
track_name: str,
682+
artist_name: str | None = None,
683+
album_name: str | None = None,
684+
track_version: str | None = None,
685+
) -> Track | None:
686+
"""Get a track by its name, optionally with artist and album."""
687+
assert self.client.server_info # for type checking
688+
if self.client.server_info.schema_version >= 27:
689+
# from schema version 27+, the server can handle this natively
690+
await self.client.send_command(
691+
"music/track_by_name",
692+
track_name=track_name,
693+
artist_name=artist_name,
694+
album_name=album_name,
695+
track_version=track_version,
696+
)
697+
return None
698+
699+
# Fallback implementation for older server versions.
700+
# TODO: remove this after a while, once all/most servers are updated
701+
702+
def compare_strings(str1: str, str2: str) -> bool:
703+
str1_compare = create_sort_name(str1)
704+
str2_compare = create_sort_name(str2)
705+
return str1_compare == str2_compare
706+
707+
search_query = f"{artist_name} - {track_name}" if artist_name else track_name
708+
search_result = await self.client.music.search(
709+
search_query=search_query,
710+
media_types=[MediaType.TRACK],
711+
)
712+
for allow_item_mapping in (False, True):
713+
for search_track in search_result.tracks:
714+
if not allow_item_mapping and not isinstance(search_track, Track):
715+
continue
716+
if not compare_strings(track_name, search_track.name):
717+
continue
718+
# check optional artist(s)
719+
if artist_name and isinstance(search_track, Track):
720+
for artist in search_track.artists:
721+
if compare_strings(artist_name, artist.name):
722+
break
723+
else:
724+
# no artist match found: abort
725+
continue
726+
# check optional album
727+
if (
728+
album_name
729+
and isinstance(search_track, Track)
730+
and search_track.album
731+
and not compare_strings(album_name, search_track.album.name)
732+
):
733+
# no album match found: abort
734+
continue
735+
# if we reach this, we found a match
736+
if not isinstance(search_track, Track):
737+
# ensure we return an actual Track object
738+
return await self.client.music.get_track(
739+
item_id=search_track.item_id,
740+
provider_instance_id_or_domain=search_track.provider,
741+
)
742+
return search_track
743+
744+
# allow non-exact album match as fallback
745+
if album_name:
746+
return await self.get_track_by_name(
747+
track_name=track_name,
748+
artist_name=artist_name,
749+
album_name=None,
750+
track_version=track_version,
751+
)
752+
# no match found
753+
return None
754+
678755
# helpers
679756

680757
def get_media_item_image(

music_assistant_client/player_queues.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,14 @@ async def get_player_queue_items(
6060
)
6161
]
6262

63-
async def get_active_queue(self, player_id: str) -> PlayerQueue:
63+
async def get_active_queue(self, player_id: str) -> PlayerQueue | None:
6464
"""Return the current active/synced queue for a player."""
65-
return PlayerQueue.from_dict(
66-
await self.client.send_command("player_queues/get_active_queue", player_id=player_id)
65+
result = await self.client.send_command(
66+
"player_queues/get_active_queue", player_id=player_id
6767
)
68+
if result:
69+
return PlayerQueue.from_dict(result)
70+
return None
6871

6972
async def queue_command_play(self, queue_id: str) -> None:
7073
"""Send PLAY command to given queue."""

music_assistant_client/players.py

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
from __future__ import annotations
44

5+
from contextlib import suppress
56
from typing import TYPE_CHECKING
67

78
from music_assistant_models.enums import EventType, MediaType
8-
from music_assistant_models.errors import PlayerCommandFailed, PlayerUnavailableError
9-
from music_assistant_models.helpers import create_sort_name
10-
from music_assistant_models.media_items import Track
9+
from music_assistant_models.errors import (
10+
MusicAssistantError,
11+
PlayerCommandFailed,
12+
PlayerUnavailableError,
13+
)
1114
from music_assistant_models.player import Player
1215

1316
if TYPE_CHECKING:
@@ -205,44 +208,69 @@ async def add_currently_playing_to_favorites(self, player_id: str) -> None:
205208
Will raise an error if the player is not currently playing anything
206209
or if the currently playing media can not be resolved to a media item.
207210
"""
211+
assert self.client.server_info # for type checking
212+
if self.client.server_info.schema_version >= 27:
213+
# if the server supports the new favorites endpoint, use that
214+
await self.client.send_command(
215+
"players/add_currently_playing_to_favorites", player_id=player_id
216+
)
217+
return
218+
# Fallback implementation for older server versions.
219+
# TODO: remove this after a while, once all/most servers are updated
220+
221+
# guard for unknown player - which should not happen, but just in case
208222
if not (player := self._players.get(player_id)):
209223
raise PlayerUnavailableError(f"Player {player_id} not found")
210-
if not player.active_source:
211-
raise PlayerCommandFailed("Player has no active source")
212-
if mass_queue := self.client.player_queues.get(player.active_source):
224+
# handle mass player queue active
225+
if mass_queue := await self.client.player_queues.get_active_queue(player_id):
213226
if not (current_item := mass_queue.current_item) or not current_item.media_item:
214227
raise PlayerCommandFailed("No current item to add to favorites")
215228
# if we're playing a radio station, try to resolve the currently playing track
216-
if (
217-
current_item.media_item.media_type == MediaType.RADIO
218-
and (streamdetails := mass_queue.current_item.streamdetails)
219-
and (stream_title := streamdetails.stream_title)
220-
and " - " in stream_title
221-
):
222-
search_result = await self.client.music.search(
223-
search_query=stream_title,
224-
media_types=[MediaType.TRACK],
225-
)
226-
for search_track in search_result.tracks:
227-
if not isinstance(search_track, Track):
228-
continue
229-
# check if the artist and title match
230-
# for now we only allow a strict match on the artist and title
231-
artist, title = stream_title.split(" - ", 1)
232-
if create_sort_name(artist) != create_sort_name(search_track.artist_str):
233-
continue
234-
if create_sort_name(title) != create_sort_name(search_track.name):
235-
continue
236-
# we found a match, add it to the favorites
237-
await self.client.music.add_item_to_favorites(search_track)
229+
if current_item.media_item.media_type == MediaType.RADIO:
230+
if not (
231+
(streamdetails := mass_queue.current_item.streamdetails)
232+
and (stream_title := streamdetails.stream_title)
233+
and " - " in stream_title
234+
):
235+
# no stream title available, so we can't resolve the track
236+
# this can happen if the radio station does not provide metadata
237+
# or there's a commercial break
238+
# Possible future improvement could be to actually detect the song with a
239+
# shazam-like approach.
240+
raise PlayerCommandFailed("No current item to add to favorites")
241+
# send the streamtitle into a global search query
242+
search_artist, search_title_title = stream_title.split(" - ", 1)
243+
if track := await self.client.music.get_track_by_name(
244+
search_title_title, search_artist
245+
):
246+
# we found a track, so add it to the favorites
247+
await self.client.music.add_item_to_favorites(track)
238248
return
239-
# any other media item, just add it to the favorites
249+
# any other media item, just add it to the favorites directly
240250
await self.client.music.add_item_to_favorites(current_item.media_item)
241251
return
242-
# handle other source active using the current_media
243-
if not (current_media := player.current_media) or not current_media.uri:
244-
raise PlayerCommandFailed("No current item to add to favorites")
245-
await self.client.music.add_item_to_favorites(current_media.uri)
252+
# guard for player with no active source
253+
if not player.active_source:
254+
raise PlayerCommandFailed("Player has no active source")
255+
# handle other source active using the current_media with uri
256+
if current_media := player.current_media:
257+
# prefer the uri of the current media item
258+
if current_media.uri:
259+
with suppress(MusicAssistantError):
260+
await self.client.music.add_item_to_favorites(current_media.uri)
261+
return
262+
# fallback to search based on artist and title (and album if available)
263+
if current_media.artist and current_media.title: # noqa: SIM102
264+
if track := await self.client.music.get_track_by_name(
265+
current_media.title,
266+
current_media.artist,
267+
current_media.album,
268+
):
269+
# we found a track, so add it to the favorites
270+
await self.client.music.add_item_to_favorites(track)
271+
return
272+
# if we reach here, we could not resolve the currently playing item
273+
raise PlayerCommandFailed("No current item to add to favorites")
246274

247275
# Other endpoints/commands
248276

0 commit comments

Comments
 (0)