image: MediaItemImage,
size: int = 0,
prefer_proxy: bool = False,
- image_format: str = "png",
+ image_format: str | None = None,
prefer_stream_server: bool = False,
) -> str:
"""Get (proxied) URL for MediaItemImage."""
+ if image_format is None:
+ image_format = "png" if image.path.lower().endswith(".png") else "jpg"
if not image.remotely_accessible or prefer_proxy or size:
# return imageproxy url for images that need to be resolved
# the original path is double encoded
provider: str,
size: int | None = None,
base64: bool = False,
- image_format: str = "png",
+ image_format: str | None = None,
) -> bytes | str:
"""Get/create thumbnail image for path (image url or local path)."""
if not self.mass.get_provider(provider) and not path.startswith("http"):
raise ProviderUnavailableError
+ if image_format is None:
+ image_format = "png" if path.lower().endswith(".png") else "jpg"
if provider == "builtin" and path.startswith("/collage/"):
# special case for collage images
path = os.path.join(self._collage_images_dir, path.split("/collage/")[-1])
# temporary for backwards compatibility
provider = "builtin"
size = int(request.query.get("size", "0"))
- image_format = request.query.get("fmt", "png")
+ image_format = request.query.get("fmt", None)
+ if image_format is None:
+ image_format = "png" if path.lower().endswith(".png") else "jpg"
if not self.mass.get_provider(provider) and not path.startswith("http"):
return web.Response(status=404)
if "%" in path:
)
return None
+ @api_command("metadata/get_track_lyrics")
+ async def get_track_lyrics(
+ self,
+ track: Track,
+ ) -> tuple[str | None, str | None]:
+ """
+ Get lyrics for given track from metadata providers.
+
+ Returns a tuple of (lyrics, lrc_lyrics) if found.
+ """
+ if track.metadata and track.metadata.lyrics:
+ return track.metadata.lyrics, track.metadata.lrc_lyrics
+
+ if track.provider == "library":
+ # try to update metadata first
+ await self._update_track_metadata(track, force_refresh=False)
+ return track.metadata.lyrics, track.metadata.lrc_lyrics
+
+ # prefer lyrics from the track's own provider
+ track_provider = self.mass.get_provider(track.provider, provider_type=MusicProvider)
+ if track_provider and ProviderFeature.LYRICS in track_provider.supported_features:
+ full_track = await self.mass.music.tracks.get_provider_item(
+ track.item_id, track.provider
+ )
+ if full_track.metadata and full_track.metadata.lyrics:
+ return full_track.metadata.lyrics, full_track.metadata.lrc_lyrics
+
+ # fallback to other metadata providers
+ for provider in self.providers:
+ if ProviderFeature.LYRICS not in provider.supported_features:
+ continue
+ if (metadata := await provider.get_track_metadata(track)) and (
+ metadata.lyrics or metadata.lrc_lyrics
+ ):
+ return metadata.lyrics, metadata.lrc_lyrics
+ return None, None
+
async def _update_artist_metadata(self, artist: Artist, force_refresh: bool = False) -> None:
"""Get/update rich metadata for an artist."""
# collect metadata from all (online) music + metadata providers
):
await self.cleanup_provider(removed_provider)
# schedule cleanup task for matching provider instances
- last_scan = cast("int", self.config.get_value(LAST_PROVIDER_INSTANCE_SCAN, 0))
+ last_scan = cast(
+ "int",
+ self.mass.config.get_raw_core_config_value(self.domain, LAST_PROVIDER_INSTANCE_SCAN, 0),
+ )
if time.time() - last_scan > PROVIDER_INSTANCE_SCAN_INTERVAL:
self.mass.call_later(60, self.correct_multi_instance_provider_mappings)
next_item_id: str | None
current_item: QueueItem | None
elapsed_time: int
+ # last_playing_elapsed_time: elapsed time from the last PLAYING state update
+ # used to determine if a track was fully played when transitioning to idle
+ last_playing_elapsed_time: int
stream_title: str | None
codec_type: ContentType | None
output_formats: list[str] | None
# Queue commands
@api_command("player_queues/shuffle")
- def set_shuffle(self, queue_id: str, shuffle_enabled: bool) -> None:
+ async def set_shuffle(self, queue_id: str, shuffle_enabled: bool) -> None:
"""Configure shuffle setting on the the queue."""
queue = self._queues[queue_id]
if queue.shuffle_enabled == shuffle_enabled:
if not shuffle_enabled:
# shuffle disabled, try to restore original sort order of the remaining items
next_items.sort(key=lambda x: x.sort_index, reverse=False)
- self.load(
+ await self.load(
queue_id=queue_id,
queue_items=next_items,
insert_at_index=next_index,
# handle replace: clear all items and replace with the new items
if option == QueueOption.REPLACE:
- self.load(
+ await self.load(
queue_id,
queue_items=queue_items,
keep_remaining=False,
return
# handle next: add item(s) in the index next to the playing/loaded/buffered index
if option == QueueOption.NEXT:
- self.load(
+ await self.load(
queue_id,
queue_items=queue_items,
insert_at_index=insert_at_index,
)
return
if option == QueueOption.REPLACE_NEXT:
- self.load(
+ await self.load(
queue_id,
queue_items=queue_items,
insert_at_index=insert_at_index,
return
# handle play: replace current loaded/playing index with new item(s)
if option == QueueOption.PLAY:
- self.load(
+ await self.load(
queue_id,
queue_items=queue_items,
insert_at_index=insert_at_index,
return
# handle add: add/append item(s) to the remaining queue items
if option == QueueOption.ADD:
- self.load(
+ await self.load(
queue_id=queue_id,
queue_items=queue_items,
insert_at_index=insert_at_index
target_queue.current_item.queue_id = target_queue_id
self.clear(source_queue_id)
- self.load(target_queue_id, source_items, keep_remaining=False, keep_played=False)
+ await self.load(target_queue_id, source_items, keep_remaining=False, keep_played=False)
for item in source_items:
item.queue_id = target_queue_id
self.update_items(target_queue_id, source_items)
# Main queue manipulation methods
- def load(
+ async def load(
self,
queue_id: str,
queue_items: list[QueueItem],
item.sort_index += insert_at_index + index
# (re)shuffle the final batch if needed
if shuffle:
- next_items = _smart_shuffle(next_items)
+ next_items = await _smart_shuffle(next_items)
self.update_items(queue_id, prev_items + next_items)
def update_items(self, queue_id: str, queue_items: list[QueueItem]) -> None:
)
# always send the base event
self.mass.signal_event(EventType.QUEUE_UPDATED, object_id=queue_id, data=queue)
+ # also signal update to the player itself so it can update its current_media
+ self.mass.players.trigger_player_update(queue_id)
# save state
self.mass.create_task(
self.mass.cache.set(
tracks = await self._get_radio_tracks(queue_id=queue_id, is_initial_radio_mode=False)
# fill queue - filter out unavailable items
queue_items = [QueueItem.from_media_item(queue_id, x) for x in tracks if x.available]
- self.load(
+ await self.load(
queue_id,
queue_items,
insert_at_index=len(self._queue_items[queue_id]) + 1,
next_item_id=None,
current_item=None,
elapsed_time=0,
+ last_playing_elapsed_time=0,
stream_title=None,
codec_type=None,
output_formats=None,
),
)
+ # update last_playing_elapsed_time only when the player is actively playing
+ # use corrected_elapsed_time which accounts for time since last update
+ # this preserves the last known elapsed time when transitioning to idle/paused
+ prev_playing_elapsed = prev_state["last_playing_elapsed_time"]
+ prev_item_id = prev_state["current_item_id"]
+ current_item_id = queue.current_item.queue_item_id if queue.current_item else None
+ if queue.state == PlaybackState.PLAYING:
+ current_elapsed = int(queue.corrected_elapsed_time)
+ if current_item_id != prev_item_id:
+ # new track started, reset the elapsed time tracker
+ last_playing_elapsed_time = current_elapsed
+ else:
+ # same track, use the max of current and previous to handle timing issues
+ last_playing_elapsed_time = max(current_elapsed, prev_playing_elapsed)
+ else:
+ last_playing_elapsed_time = prev_playing_elapsed
new_state = CompareState(
queue_id=queue_id,
state=queue.state,
next_item_id=queue.next_item.queue_item_id if queue.next_item else None,
current_item=queue.current_item,
elapsed_time=int(queue.elapsed_time),
+ last_playing_elapsed_time=last_playing_elapsed_time,
stream_title=(
queue.current_item.streamdetails.stream_title
if queue.current_item and queue.current_item.streamdetails
changed_keys = get_changed_keys(dict(prev_state), dict(new_state))
with suppress(KeyError):
changed_keys.remove("next_item_id")
+ with suppress(KeyError):
+ changed_keys.remove("last_playing_elapsed_time")
# store the new state
if queue.active:
if send_update:
self.signal_update(queue_id)
- # also signal update to the player itself so it can update its current_media
- self.mass.players.trigger_player_update(queue_id)
if "output_formats" in changed_keys:
# refresh DSP details since they may have changed
# all checks passed, we stopped playback at the last (or single) track of the queue
# now determine if the item was fully played before clearing
- if queue.current_item and (streamdetails := queue.current_item.streamdetails):
- duration = streamdetails.duration or queue.current_item.duration or 24 * 3600
- elif queue.current_item:
- duration = queue.current_item.duration or 24 * 3600
+
+ # For flow mode, check if the last track was fully streamed using the stream log
+ # This is more reliable than elapsed_time which can be reset/incorrect
+ if queue.flow_mode and queue.flow_mode_stream_log:
+ last_log_entry = queue.flow_mode_stream_log[-1]
+ if last_log_entry.seconds_streamed is not None:
+ # The last track finished streaming, safe to clear queue
+ self.mass.create_task(_clear_queue_delayed())
+ return
+
+ # For non-flow mode, use prev_state values since queue state may have been updated/reset
+ prev_item = prev_state["current_item"]
+ if prev_item and (streamdetails := prev_item.streamdetails):
+ duration = streamdetails.duration or prev_item.duration or 24 * 3600
+ elif prev_item:
+ duration = prev_item.duration or 24 * 3600
else:
# No current item means player has already cleared it, safe to clear queue
self.mass.create_task(_clear_queue_delayed())
return
- seconds_played = int(queue.elapsed_time)
+ # use last_playing_elapsed_time which preserves the elapsed time from when the player
+ # was still playing (before transitioning to idle where elapsed_time may be reset to 0)
+ seconds_played = int(prev_state["last_playing_elapsed_time"])
# debounce this a bit to make sure we're not clearing the queue by accident
# only clear if the last track was played to near completion (within 5 seconds of end)
if seconds_played >= (duration or 3600) - 5:
)
-def _smart_shuffle(items: list[QueueItem]) -> list[QueueItem]:
- """Shuffle queue items with smart spacing rules.
+async def _smart_shuffle(items: list[QueueItem]) -> list[QueueItem]:
+ """Shuffle queue items, avoiding identical tracks next to each other.
- This shuffle tries to prevent the same track and artist from appearing
- too close together. Spacing requirements scale with playlist size:
- - >1000 items: track spacing 15, artist spacing 10
- - >500 items: track spacing 10, artist spacing 6
- - >100 items: track spacing 5, artist spacing 3
- - <=100 items: track spacing 2, no artist spacing
-
- This is a best-effort approach - when playing an album where all tracks
- are from the same artist, artist spacing won't be possible.
+ Best-effort approach to prevent the same track from appearing adjacent.
+ Does a random shuffle first, then makes a limited number of passes to
+ swap adjacent duplicates with a random item further in the list.
:param items: List of queue items to shuffle.
"""
- if len(items) <= 1:
- return items
-
- # Determine spacing based on playlist size
- num_items = len(items)
- if num_items > 1000:
- track_spacing, artist_spacing = 15, 10
- elif num_items > 500:
- track_spacing, artist_spacing = 10, 6
- elif num_items > 100:
- track_spacing, artist_spacing = 5, 3
- else:
- track_spacing, artist_spacing = 2, 0
-
- # Extract artist from name format "<artist(s)> - <title>"
- def get_artist(name: str) -> str | None:
- return name.split(" - ", 1)[0] if " - " in name else None
+ if len(items) <= 2:
+ return random.sample(items, len(items)) if len(items) == 2 else items
# Start with a random shuffle
shuffled = random.sample(items, len(items))
- # Iteratively fix violations
- max_attempts = len(items) * 3
- for _ in range(max_attempts):
- violation_found = False
-
- for i in range(1, len(shuffled)):
- current = shuffled[i]
- current_artist = get_artist(current.name)
-
- # Check for track collision
- has_violation = any(
- shuffled[j].name == current.name for j in range(max(0, i - track_spacing), i)
- )
-
- # Check for artist collision (only if artist_spacing > 0)
- if not has_violation and artist_spacing and current_artist:
- has_violation = any(
- get_artist(shuffled[j].name) == current_artist
- for j in range(max(0, i - artist_spacing), i)
- )
-
- if has_violation:
- violation_found = True
- # Find best position after current by scoring distance from conflicts
- best_pos, best_score = i, -1
- for pos in range(i + 1, len(shuffled)):
- track_dist = min(
- (
- pos - j
- for j in range(max(0, pos - track_spacing), pos)
- if shuffled[j].name == current.name
- ),
- default=track_spacing,
- )
- artist_dist = artist_spacing
- if artist_spacing and current_artist:
- artist_dist = min(
- (
- pos - j
- for j in range(max(0, pos - artist_spacing), pos)
- if get_artist(shuffled[j].name) == current_artist
- ),
- default=artist_spacing,
- )
- score = track_dist * 2 + artist_dist
- if score > best_score:
- best_score, best_pos = score, pos
-
- if best_pos != i:
- item = shuffled.pop(i)
- shuffled.insert(best_pos, item)
- break
-
- if not violation_found:
+ # Make a few passes to fix adjacent duplicates
+ max_passes = 3
+ for _ in range(max_passes):
+ swapped = False
+ for i in range(len(shuffled) - 1):
+ if shuffled[i].name == shuffled[i + 1].name:
+ # Found adjacent duplicate - swap with random position at least 2 away
+ swap_candidates = [j for j in range(len(shuffled)) if abs(j - i - 1) >= 2]
+ if swap_candidates:
+ swap_pos = random.choice(swap_candidates)
+ shuffled[i + 1], shuffled[swap_pos] = shuffled[swap_pos], shuffled[i + 1]
+ swapped = True
+ if not swapped:
break
+ # Yield to event loop between passes
+ await asyncio.sleep(0)
return shuffled
player.display_name,
)
# signal event that a player was added
+ # update state without signaling event first (to ensure all attributes are set correctly)
+ player.update_state(signal_event=False)
self.mass.signal_event(EventType.PLAYER_ADDED, object_id=player.player_id, data=player)
# register playerqueue for this player
LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.auth")
-async def get_ha_user_role(mass: MusicAssistant, ha_user_id: str) -> UserRole:
+async def get_ha_user_role(
+ mass: MusicAssistant, ha_user_id: str, wait_timeout: float = 30.0
+) -> UserRole:
"""
Get user role based on Home Assistant admin status.
:param mass: MusicAssistant instance.
:param ha_user_id: The Home Assistant user ID to check.
+ :param wait_timeout: Maximum time to wait for HA provider to become available (default 30s).
"""
try:
- hass_prov = mass.get_provider("hass")
+ # Wait for the HA provider to become available (handles race condition at startup)
+ hass_prov = None
+ wait_interval = 0.5
+ elapsed = 0.0
+ while elapsed < wait_timeout:
+ hass_prov = mass.get_provider("hass")
+ if hass_prov is not None and hass_prov.available:
+ break
+ await asyncio.sleep(wait_interval)
+ elapsed += wait_interval
+ hass_prov = None # Reset to None for the final check
+
if hass_prov is None or not hass_prov.available:
raise RuntimeError("Home Assistant provider not available")
- hass_prov = cast("HomeAssistantProvider", hass_prov)
+ if TYPE_CHECKING:
+ hass_prov = cast("HomeAssistantProvider", hass_prov)
# Query HA for user list to check admin status
result = await hass_prov.hass.send_command("config/auth/list")
if not result:
if not size and image_format.encode() in img_data:
return img_data
+ image_format = image_format.upper()
+ if image_format == "JPG":
+ image_format = "JPEG"
+
def _create_image() -> bytes:
data = BytesIO()
try:
def _is_port_in_use() -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as _sock:
+ # Set SO_REUSEADDR to match asyncio.start_server behavior
+ # This allows binding to ports in TIME_WAIT state
+ _sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
_sock.bind(("0.0.0.0", port))
except OSError:
return True
return False
- try:
- if await check_output(f"lsof -i :{port}"):
- return True
- except Exception:
- # lsof not available (or some other error), fallback to socket check
- return await asyncio.to_thread(_is_port_in_use)
+ return await asyncio.to_thread(_is_port_in_use)
async def select_free_port(range_start: int, range_end: int) -> int:
return self._state
@final
- def update_state(self, force_update: bool = False) -> None:
+ def update_state(self, force_update: bool = False, signal_event: bool = True) -> None:
"""
Update the PlayerState with the current state of the player.
:param force_update: If True, a state update event will be
pushed even if the state has not actually changed.
+ :param signal_event: If True, signal the state update event to the PlayerController.
"""
self.mass.verify_event_loop_thread("player.update_state")
# clear the dict for the cached properties
if len(changed_values) == 0 and not force_update:
return
# signal the state update to the PlayerController
- self.mass.players.signal_player_state_update(self, changed_values)
+ if signal_event:
+ self.mass.players.signal_player_state_update(self, changed_values)
@final
def set_current_media( # noqa: PLR0913
elapsed_time=int(active_queue.elapsed_time),
elapsed_time_last_updated=active_queue.elapsed_time_last_updated,
)
+ elif active_queue:
+ # queue is active but no current item
+ return None
# return native current media if no group/queue is active
if self._current_media:
return PlayerMedia(
queue = self.mass.player_queues.get(player_id)
if not queue:
return
- self.mass.player_queues.set_shuffle(
+ await self.mass.player_queues.set_shuffle(
active_queue.queue_id, not queue.shuffle_enabled
)
elif path in ("/ctrl-int/1/pause", "/ctrl-int/1/discrete-pause"):
SUPPORTED_FEATURES = {
ProviderFeature.TRACK_METADATA,
+ ProviderFeature.LYRICS,
}
from .constants import OFF_STATES, MediaPlayerEntityFeature
if TYPE_CHECKING:
- from hass_client.models import CompressedState, EntityStateEvent
+ from hass_client.models import CompressedState, Device, EntityStateEvent
from music_assistant_models.config_entries import ProviderConfig
from music_assistant_models.provider import ProviderManifest
if not hass.connected:
return ()
for state in await hass.get_states():
- if "friendly_name" not in state["attributes"]:
- # filter out invalid/unavailable players
- continue
entity_platform = state["entity_id"].split(".")[0]
- name = f"{state['attributes']['friendly_name']} ({state['entity_id']})"
+ if "friendly_name" not in state["attributes"]:
+ name = state["entity_id"]
+ else:
+ name = f"{state['attributes']['friendly_name']} ({state['entity_id']})"
if entity_platform in ("switch", "input_boolean"):
# simple on/off controls are suitable as power and mute controls
service_data={"value": volume_level},
)
+ async def get_device_by_connection(
+ self,
+ connection_value: str,
+ connection_type: str = "mac",
+ ) -> Device | None:
+ """
+ Get device details from Home Assistant by connection type and value.
+
+ :param connection_value: The connection value (e.g. MAC address).
+ :param connection_type: The connection type (default: 'mac').
+ """
+ devices = await self.hass.get_device_registry()
+ for device in devices:
+ for connection in device.get("connections", []):
+ if (
+ len(connection) == 2
+ and connection[0] == connection_type
+ and connection[1].lower() == connection_value.lower()
+ ):
+ return device
+ return None
+
def _update_control_from_state_msg(self, entity_id: str, state: CompressedState) -> None:
"""Update PlayerControl from state(update) message."""
if self._player_controls is None:
SUPPORTED_FEATURES = {
ProviderFeature.TRACK_METADATA,
+ ProviderFeature.LYRICS,
}
CONF_API_URL = "api_url"
# Set shuffle if requested
if shuffle:
- self.provider.mass.player_queues.set_shuffle(player_id, shuffle)
+ await self.provider.mass.player_queues.set_shuffle(player_id, shuffle)
# Seek to offset if specified
if offset > 0:
# Apply shuffle if requested
if shuffle:
- self.provider.mass.player_queues.set_shuffle(player_id, shuffle)
+ await self.provider.mass.player_queues.set_shuffle(player_id, shuffle)
# Seek to offset if specified
if offset > 0:
return web.Response(status=500, text="No player assigned")
# disable shuffle to avoid infinite loop
- self.provider.mass.player_queues.set_shuffle(player_id, False)
+ await self.provider.mass.player_queues.set_shuffle(player_id, False)
ma_queue = self.provider.mass.player_queues.get(player_id)
if not ma_queue:
LOGGER.error(f"MA queue not found for player {player_id}")
# Apply shuffle if requested (Plex may have already shuffled server-side)
if shuffle:
- self.provider.mass.player_queues.set_shuffle(player_id, shuffle)
+ await self.provider.mass.player_queues.set_shuffle(player_id, shuffle)
else:
LOGGER.error("No valid tracks in created play queue")
return web.Response(status=500, text="Failed to load tracks from play queue")
if "shuffle" in request.query:
# Plex sends shuffle as "0" or "1"
shuffle = request.query["shuffle"] == "1"
- self.provider.mass.player_queues.set_shuffle(self._ma_player_id, shuffle)
+ await self.provider.mass.player_queues.set_shuffle(self._ma_player_id, shuffle)
if "repeat" in request.query:
# Plex repeat: 0=off, 1=repeat one, 2=repeat all
self._attr_volume_muted = player_client.muted
self._attr_available = True
self.is_web_player = sendspin_client.name.startswith(
- "Music Assistant Web (" # The regular Web Interface
+ "Web (" # The regular Web Interface
) or sendspin_client.name.startswith(
- "Music Assistant (" # The PWA App
+ "PWA (" # The PWA App
)
self._attr_expose_to_ha_by_default = not self.is_web_player
case MediaCommand.REPEAT_ALL if queue:
self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.ALL)
case MediaCommand.SHUFFLE if queue:
- self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=True)
+ await self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=True)
case MediaCommand.UNSHUFFLE if queue:
- self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=False)
+ await self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=False)
async def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None:
"""Event callback registered to the sendspin group this player belongs to."""
from music_assistant_models.config_entries import ProviderConfig
from music_assistant_models.provider import ProviderManifest
+ from music_assistant.providers.hass import HomeAssistantProvider
+
class SendspinProvider(PlayerProvider):
"""Player Provider for Sendspin."""
await pending_event.wait()
player = SendspinPlayer(self, client_id)
self.logger.debug("Client %s connected", client_id)
+ if player.device_info.manufacturer == "ESPHome" and (
+ hass := self.mass.get_provider("hass")
+ ):
+ # Try to get device name from Home Assistant for ESPHome devices
+ hass = cast("HomeAssistantProvider", hass)
+ if hass_device := await hass.get_device_by_connection(client_id):
+ player._attr_name = (
+ hass_device["name_by_user"] or hass_device["name"] or player.name
+ )
await self.mass.players.register(player)
case ClientRemovedEvent(client_id):
self.logger.debug("Client %s disconnected", client_id)
self.client.extra_data["playlist repeat"] = REPEATMODE_MAP[queue.repeat_mode]
self.client.signal_update()
elif event.data == "button shuffle":
- self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled)
+ await self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled)
self.client.extra_data["playlist shuffle"] = int(queue.shuffle_enabled)
self.client.signal_update()
elif event_data in ("button jump_fwd", "button fwd"):
ProviderFeature.BROWSE,
ProviderFeature.PLAYLIST_TRACKS_EDIT,
ProviderFeature.RECOMMENDATIONS,
+ ProviderFeature.LYRICS,
}