diff --git a/music_assistant/common/models/config_entries.py b/music_assistant/common/models/config_entries.py index d14b4d09e..abed3c394 100644 --- a/music_assistant/common/models/config_entries.py +++ b/music_assistant/common/models/config_entries.py @@ -3,14 +3,14 @@ from __future__ import annotations import logging -from collections.abc import Iterable # noqa: TCH003 +from collections.abc import Iterable from dataclasses import dataclass from types import NoneType from typing import Any from mashumaro import DataClassDictMixin -from music_assistant.common.models.enums import ProviderType # noqa: TCH001 +from music_assistant.common.models.enums import ProviderType from music_assistant.constants import ( CONF_AUTO_PLAY, CONF_CROSSFADE, diff --git a/music_assistant/common/models/player.py b/music_assistant/common/models/player.py index e2b8ca774..ba4e3fabf 100644 --- a/music_assistant/common/models/player.py +++ b/music_assistant/common/models/player.py @@ -25,7 +25,7 @@ class Player(DataClassDictMixin): """Representation of a Player within Music Assistant.""" player_id: str - provider: str + provider: str # instance_id of the player provider type: PlayerType name: str available: bool @@ -51,6 +51,7 @@ class Player(DataClassDictMixin): # active_source: return player_id of the active queue for this player # if the player is grouped and a group is active, this will be set to the group's player_id # otherwise it will be set to the own player_id + # can also be an actual different source if the player supports that active_source: str | None = None # current_item_id: return item_id/uri of the current active/loaded item on the player diff --git a/music_assistant/common/models/player_queue.py b/music_assistant/common/models/player_queue.py index ed3e28bb3..30b9cae71 100644 --- a/music_assistant/common/models/player_queue.py +++ b/music_assistant/common/models/player_queue.py @@ -7,10 +7,10 @@ from mashumaro import DataClassDictMixin -from music_assistant.common.models.media_items import MediaItemType # noqa: TCH001 +from music_assistant.common.models.media_items import MediaItemType from .enums import PlayerState, RepeatMode -from .queue_item import QueueItem # noqa: TCH001 +from .queue_item import QueueItem @dataclass diff --git a/music_assistant/common/models/provider.py b/music_assistant/common/models/provider.py index 732794434..114a09d55 100644 --- a/music_assistant/common/models/provider.py +++ b/music_assistant/common/models/provider.py @@ -1,7 +1,7 @@ """Models for providers and plugins in the MA ecosystem.""" from __future__ import annotations -import asyncio # noqa: TCH003 +import asyncio from dataclasses import dataclass, field from typing import Any, TypedDict @@ -9,7 +9,7 @@ from music_assistant.common.helpers.json import load_json_file -from .enums import MediaType, ProviderFeature, ProviderType # noqa: TCH001 +from .enums import MediaType, ProviderFeature, ProviderType @dataclass diff --git a/music_assistant/server/controllers/config.py b/music_assistant/server/controllers/config.py index 4704e1442..c0331a8a2 100644 --- a/music_assistant/server/controllers/config.py +++ b/music_assistant/server/controllers/config.py @@ -320,13 +320,11 @@ async def reload_provider(self, instance_id: str) -> None: async def get_player_configs(self, provider: str | None = None) -> list[PlayerConfig]: """Return all known player configurations, optionally filtered by provider domain.""" available_providers = {x.instance_id for x in self.mass.providers} - # add both domain and instance id - available_providers.update({x.domain for x in self.mass.providers}) return [ await self.get_player_config(raw_conf["player_id"]) for raw_conf in list(self.get(CONF_PLAYERS, {}).values()) # filter out unavailable providers - if self.mass.get_provider(raw_conf["provider"]) + if raw_conf["provider"] in available_providers # optional provider filter and (provider in (None, raw_conf["provider"])) ] diff --git a/music_assistant/server/controllers/media/playlists.py b/music_assistant/server/controllers/media/playlists.py index a7a801643..91eb00adc 100644 --- a/music_assistant/server/controllers/media/playlists.py +++ b/music_assistant/server/controllers/media/playlists.py @@ -5,7 +5,7 @@ import asyncio import random import time -from collections.abc import AsyncGenerator # noqa: TCH003 +from collections.abc import AsyncGenerator from typing import Any from music_assistant.common.helpers.datetime import utc_timestamp diff --git a/music_assistant/server/controllers/metadata.py b/music_assistant/server/controllers/metadata.py index 072bfb514..0de9f97ac 100644 --- a/music_assistant/server/controllers/metadata.py +++ b/music_assistant/server/controllers/metadata.py @@ -329,9 +329,11 @@ async def get_image_url_for_item( return None - def get_image_url(self, image: MediaItemImage, size: int = 0) -> str: + def get_image_url( + self, image: MediaItemImage, size: int = 0, prefer_proxy: bool = False + ) -> str: """Get (proxied) URL for MediaItemImage.""" - if image.provider != "url": + if image.provider != "url" or prefer_proxy or size: # return imageproxy url for images that need to be resolved # the original path is double encoded encoded_url = urllib.parse.quote(urllib.parse.quote(image.path)) diff --git a/music_assistant/server/controllers/music.py b/music_assistant/server/controllers/music.py index 86cba133c..50a274d1f 100644 --- a/music_assistant/server/controllers/music.py +++ b/music_assistant/server/controllers/music.py @@ -6,7 +6,7 @@ import os import shutil import statistics -from collections.abc import AsyncGenerator # noqa: TCH003 +from collections.abc import AsyncGenerator from contextlib import suppress from itertools import zip_longest from typing import TYPE_CHECKING diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py index e14ad0b83..0b0390259 100644 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -5,7 +5,7 @@ import logging import random import time -from collections.abc import AsyncGenerator # noqa: TCH003 +from collections.abc import AsyncGenerator from contextlib import suppress from typing import TYPE_CHECKING, Any diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index 04c7ebdd0..9608742f2 100644 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -168,6 +168,12 @@ def register(self, player: Player) -> None: msg = f"Player {player_id} is already registered" raise AlreadyRegisteredError(msg) + # make sure that the player's provider is set to the instance id + if prov := self.mass.get_provider(player.provider): + player.provider = prov.instance_id + else: + raise RuntimeError("Invalid provider ID given: %s", player.provider) + # make sure a default config exists self.mass.config.create_default_player_config( player_id, player.provider, player.name, player.enabled_by_default @@ -203,6 +209,7 @@ def register_or_update(self, player: Player) -> None: return if player.player_id in self._players: + self._players[player.player_id] = player self.update(player.player_id) return @@ -665,6 +672,12 @@ async def cmd_sync(self, player_id: str, target_player: str) -> None: elif child_player.state == PlayerState.PLAYING: # stop child player if it is currently playing await self.cmd_stop(player_id) + if player_id not in parent_player.can_sync_with: + raise RuntimeError( + "Player %s can not be synced with %s", + child_player.display_name, + parent_player.display_name, + ) # all checks passed, forward command to the player provider player_provider = self.get_player_provider(player_id) await player_provider.cmd_sync(player_id, target_player) @@ -695,6 +708,8 @@ async def cmd_unsync(self, player_id: str) -> None: # all checks passed, forward command to the player provider player_provider = self.get_player_provider(player_id) await player_provider.cmd_unsync(player_id) + # reset active_source just in case + player.active_source = None @api_command("players/create_group") async def create_group(self, provider: str, name: str, members: list[str]) -> Player: diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index 4ee75d097..f678a61a5 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -12,7 +12,7 @@ import logging import time import urllib.parse -from collections.abc import AsyncGenerator # noqa: TCH003 +from collections.abc import AsyncGenerator from contextlib import suppress from typing import TYPE_CHECKING diff --git a/music_assistant/server/providers/airplay/__init__.py b/music_assistant/server/providers/airplay/__init__.py index 9cdd29ea4..000e7f9c1 100644 --- a/music_assistant/server/providers/airplay/__init__.py +++ b/music_assistant/server/providers/airplay/__init__.py @@ -7,12 +7,10 @@ import platform import socket import time +from collections.abc import AsyncGenerator from random import randint, randrange from typing import TYPE_CHECKING, cast -import aiofiles -import shortuuid -from aiofiles.os import wrap from pyatv import connect, exceptions, interface, scan from pyatv.conf import AppleTV as ATVConf from pyatv.const import DeviceModel, DeviceState, PowerState, Protocol @@ -40,6 +38,7 @@ ) from music_assistant.common.models.media_items import AudioFormat from music_assistant.common.models.player import DeviceInfo, Player +from music_assistant.common.models.player_queue import PlayerQueue from music_assistant.server.helpers.process import AsyncProcess, check_output from music_assistant.server.models.player_provider import PlayerProvider @@ -58,6 +57,7 @@ CONF_ALAC_ENCODE = "alac_encode" CONF_VOLUME_START = "volume_start" CONF_SYNC_ADJUST = "sync_adjust" +CONF_PASSWORD = "password" PLAYER_CONFIG_ENTRIES = ( CONF_ENTRY_CROSSFADE, CONF_ENTRY_CROSSFADE_DURATION, @@ -111,6 +111,15 @@ "you can shift the audio a bit.", advanced=True, ), + ConfigEntry( + key=CONF_PASSWORD, + type=ConfigEntryType.STRING, + default_value=None, + required=False, + label="Device password", + description="Some devices require a password to connect/play.", + advanced=True, + ), ) BACKOFF_TIME_LOWER_LIMIT = 15 # seconds BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes @@ -591,7 +600,7 @@ async def _handle_dacp_request( # noqa: PLR0915 if abs(volume - int(atv_player.atv.audio.volume)) > 2: self.mass.create_task(self.cmd_volume_set(player_id, volume)) else: - self.logger.warning( + self.logger.debug( "Unknown DACP request for %s: %s", atv_player.discovery_info.name, path, @@ -680,14 +689,62 @@ async def play_media( """ # stop existing streams first await self.cmd_stop(player_id) + # power on player if needed + # start streaming the queue (pcm) audio in a background task + queue = self.mass.player_queues.get_active_queue(player_id) + self._stream_tasks[player_id] = asyncio.create_task( + self._stream_audio( + player_id, + queue=queue, + audio_iterator=self.mass.streams.get_flow_stream( + queue, + start_queue_item=queue_item, + pcm_format=AudioFormat( + content_type=ContentType.PCM_S16LE, + sample_rate=44100, + bit_depth=16, + channels=2, + ), + seek_position=seek_position, + fade_in=fade_in, + ), + ) + ) + + async def play_stream(self, player_id: str, stream_job: MultiClientStreamJob) -> None: + """Handle PLAY STREAM on given player. + + This is a special feature from the Universal Group provider. + """ + # stop existing streams first + await self.cmd_stop(player_id) + # power on player if needed await self.cmd_power(player_id, True) - atv_player = self._atv_players[player_id] - player = self.mass.players.get(player_id) + if stream_job.pcm_format.bit_depth != 16 or stream_job.pcm_format.sample_rate != 44100: + # TODO: resample on the fly here ? + raise RuntimeError("Unsupported PCM format") + # start streaming the queue (pcm) audio in a background task + queue = self.mass.player_queues.get_active_queue(player_id) + self._stream_tasks[player_id] = asyncio.create_task( + self._stream_audio( + player_id, + queue=queue, + audio_iterator=stream_job.subscribe(player_id), + ) + ) + async def _stream_audio( + self, player_id: str, queue: PlayerQueue, audio_iterator: AsyncGenerator[bytes, None] + ) -> None: + """Handle the actual streaming of audio to Airplay.""" + player = self.mass.players.get(player_id) if player.synced_to: # should not happen, but just in case raise RuntimeError("Player is synced") - + player.elapsed_time = 0 + player.elapsed_time_last_updated = time.time() + player.state = PlayerState.PLAYING + self.mass.players.update(player_id) # NOTE: Although the pyatv library is perfectly capable of playback # to not only raop targets but also airplay 1 + 2, its not suitable # for synced playback to multiple clients at once. @@ -709,76 +766,42 @@ async def play_media( # just in case... await atv_player.connect() tg.create_task(self._init_cliraop(atv_player, ntp)) - - async def _streamer() -> None: - queue = self.mass.player_queues.get(queue_item.queue_id) - player.current_item_id = f"{queue_item.queue_id}.{queue_item.queue_item_id}" - player.elapsed_time = 0 - player.elapsed_time_last_updated = time.time() - player.state = PlayerState.PLAYING - self.mass.players.register_or_update(player) - prev_metadata_checksum: str = "" - pcm_format = AudioFormat( - content_type=ContentType.PCM_S16LE, - sample_rate=44100, - bit_depth=16, - channels=2, - ) - try: - async for pcm_chunk in self.mass.streams.get_flow_stream( - queue, - start_queue_item=queue_item, - pcm_format=pcm_format, - seek_position=seek_position, - fade_in=fade_in, - ): - # send metadata to player(s) if needed - # NOTE: this must all be done in separate tasks to not disturb audio - if queue and queue.current_item and queue.current_item.streamdetails: - metadata_checksum = ( - queue.current_item.streamdetails.stream_title - or queue.current_item.queue_item_id - ) - if prev_metadata_checksum != metadata_checksum: - prev_metadata_checksum = metadata_checksum - self.mass.create_task(self._send_metadata(player_id)) - - # send audio chunk to player(s) - async with asyncio.TaskGroup() as tg: - available_clients = 0 + prev_metadata_checksum: str = "" + try: + async for pcm_chunk in audio_iterator: + # send metadata to player(s) if needed + # NOTE: this must all be done in separate tasks to not disturb audio + if queue and queue.current_item and queue.current_item.streamdetails: + metadata_checksum = ( + queue.current_item.streamdetails.stream_title + or queue.current_item.queue_item_id + ) + if prev_metadata_checksum != metadata_checksum: + prev_metadata_checksum = metadata_checksum + self.mass.create_task(self._send_metadata(player_id)) + + async with asyncio.TaskGroup() as tg: + # send progress metadata + if queue.elapsed_time: for atv_player in self._get_sync_clients(player_id): - if not atv_player.cliraop_proc or atv_player.cliraop_proc.closed: - # this may not happen, but just in case - continue - available_clients += 1 - tg.create_task(atv_player.cliraop_proc.write(pcm_chunk)) - if not available_clients: - return - - # send progress metadata - if queue.elapsed_time: - for atv_player in self._get_sync_clients(player_id): - tg.create_task( - atv_player.send_cli_command( - f"PROGRESS={int(queue.elapsed_time)}\n" - ) - ) - - finally: - self.logger.debug("Streamer task ended for player %s", queue.display_name) - for atv_player in self._get_sync_clients(player_id): - if atv_player.cliraop_proc and not atv_player.cliraop_proc.closed: - atv_player.cliraop_proc.write_eof() - - # start streaming the queue (pcm) audio in a background task - self._stream_tasks[player_id] = asyncio.create_task(_streamer()) - - async def play_stream(self, player_id: str, stream_job: MultiClientStreamJob) -> None: - """Handle PLAY STREAM on given player. - - This is a special feature from the Universal Group provider. - """ - raise NotImplementedError + tg.create_task( + atv_player.send_cli_command(f"PROGRESS={int(queue.elapsed_time)}\n") + ) + # send audio chunk to player(s) + available_clients = 0 + for atv_player in self._get_sync_clients(player_id): + if not atv_player.cliraop_proc or atv_player.cliraop_proc.closed: + # this may not happen, but just in case + continue + available_clients += 1 + tg.create_task(atv_player.cliraop_proc.write(pcm_chunk)) + if not available_clients: + return + finally: + self.logger.debug("Streaming ended for player %s", player.display_name) + for atv_player in self._get_sync_clients(player_id): + if atv_player.cliraop_proc and not atv_player.cliraop_proc.closed: + atv_player.cliraop_proc.write_eof() async def cmd_power(self, player_id: str, powered: bool) -> None: """Send POWER command to given player. @@ -806,7 +829,7 @@ async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: if atv_player.cliraop_proc: # prefer interactive command to our streamer await atv_player.send_cli_command(f"VOLUME={volume_level}\n") - elif atv := atv_player.atv: + if atv := atv_player.atv: await atv.audio.set_volume(volume_level) async def cmd_sync(self, player_id: str, target_player: str) -> None: @@ -843,8 +866,7 @@ async def cmd_unsync(self, player_id: str) -> None: group_leader = self.mass.players.get(player.synced_to, raise_unavailable=True) group_leader.group_childs.remove(player_id) player.synced_to = None - if player.state == PlayerState.PLAYING: - await self.cmd_stop(player_id) + await self.cmd_stop(player_id) self.mass.players.update(player_id) async def _run_discovery(self) -> None: @@ -962,12 +984,16 @@ async def log_watcher(cliraop_proc: AsyncProcess) -> None: logger = self.logger.getChild(atv_player.player_id) async for line in cliraop_proc._proc.stderr: line = line.decode().strip() # noqa: PLW2901 + if not line: + continue if "set pause" in line: atv_player.optimistic_state = PlayerState.PAUSED atv_player.update_attributes() - if "Restarted at" in line: + logger.info(line) + elif "Restarted at" in line: atv_player.optimistic_state = PlayerState.PLAYING atv_player.update_attributes() + logger.info(line) elif "after start), played" in line: millis = int(line.split("played ")[1].split(" ")[0]) mass_player.elapsed_time = millis / 1000 @@ -1002,6 +1028,10 @@ async def log_watcher(cliraop_proc: AsyncProcess) -> None: sync_adjust = self.mass.config.get_raw_player_config_value( atv_player.player_id, CONF_SYNC_ADJUST, 0 ) + if device_password := self.mass.config.get_raw_player_config_value( + atv_player.player_id, CONF_PASSWORD, None + ): + extra_args += ["-P", device_password] if self.logger.level == logging.DEBUG: extra_args += ["-d", "5"] @@ -1070,19 +1100,9 @@ async def _send_metadata(self, player_id: str) -> None: # get image if not queue.current_item.image: return - temp_image_path = f"/tmp/{shortuuid.random(12)}" # noqa: S108 - image_data = await self.mass.metadata.get_thumbnail( - queue.current_item.image.path, - 512, - queue.current_item.image.provider, + + image_url = self.mass.metadata.get_image_url( + queue.current_item.image, size=512, prefer_proxy=True ) - if not image_data: - return - async with aiofiles.open(temp_image_path, "wb") as outfile: - await outfile.write(image_data) for atv_player in self._get_sync_clients(player_id): - await atv_player.send_cli_command(f"ARTWORK={temp_image_path}\n") - # make sure the temp file gets deleted again - await asyncio.sleep(30) - rm_func = wrap(os.remove) - await rm_func(temp_image_path) + await atv_player.send_cli_command(f"ARTWORK={image_url}\n") diff --git a/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 b/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 index 0bd6d2716..ea1044350 100755 Binary files a/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 and b/music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64 differ diff --git a/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 b/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 index eefe426a3..c6a24fb1f 100755 Binary files a/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 and b/music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64 differ diff --git a/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 b/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 index e04e68da9..09a31d971 100755 Binary files a/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 and b/music_assistant/server/providers/airplay/bin/cliraop-macos-arm64 differ diff --git a/music_assistant/server/providers/slimproto/manifest.json b/music_assistant/server/providers/slimproto/manifest.json index 4b5e29d6f..cc0e4aed7 100644 --- a/music_assistant/server/providers/slimproto/manifest.json +++ b/music_assistant/server/providers/slimproto/manifest.json @@ -3,10 +3,14 @@ "domain": "slimproto", "name": "Slimproto", "description": "Support for slimproto based players (e.g. squeezebox, squeezelite).", - "codeowners": ["@music-assistant"], - "requirements": ["aioslimproto==2.3.3"], + "codeowners": [ + "@music-assistant" + ], + "requirements": [ + "aioslimproto==2.3.3" + ], "documentation": "https://music-assistant.github.io/player-support/slimproto/", "multi_instance": false, "builtin": false, - "load_by_default": true + "load_by_default": false } diff --git a/music_assistant/server/providers/snapcast/__init__.py b/music_assistant/server/providers/snapcast/__init__.py index c7fcccfa9..edb8df36f 100644 --- a/music_assistant/server/providers/snapcast/__init__.py +++ b/music_assistant/server/providers/snapcast/__init__.py @@ -314,13 +314,9 @@ async def play_stream(self, player_id: str, stream_job: MultiClientStreamJob) -> raise RuntimeError(msg) # stop any existing streams first await self.cmd_stop(player_id) - # TEMP - TODO - WARNING - ACHTUNG - HACK - # override pcm format of streamjob due to issue with snapcast - # that seems to only accept a 48000/16 stream somehow ?! - stream_job.pcm_format.content_type = ContentType.PCM_S16LE - stream_job.pcm_format.sample_rate = 48000 - stream_job.pcm_format.bit_depth = 16 - # end of hack + if stream_job.pcm_format.bit_depth != 16 or stream_job.pcm_format.sample_rate != 48000: + # TODO: resample on the fly here ? + raise RuntimeError("Unsupported PCM format") stream, port = await self._create_stream() stream_job.expected_players.add(player_id) snap_group = self._get_snapgroup(player_id) diff --git a/music_assistant/server/providers/ytmusic/__init__.py b/music_assistant/server/providers/ytmusic/__init__.py index 71a4ce58d..9f2508a97 100644 --- a/music_assistant/server/providers/ytmusic/__init__.py +++ b/music_assistant/server/providers/ytmusic/__init__.py @@ -5,7 +5,7 @@ import asyncio import logging import re -from collections.abc import AsyncGenerator # noqa: TCH003 +from collections.abc import AsyncGenerator from operator import itemgetter from time import time from typing import TYPE_CHECKING diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index 43309e610..8d3b648ce 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -45,7 +45,7 @@ is_hass_supervisor, ) -from .models import ProviderInstanceType # noqa: TCH001 +from .models import ProviderInstanceType if TYPE_CHECKING: from types import TracebackType diff --git a/pyproject.toml b/pyproject.toml index 6ea92cd48..590ae6bfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -191,6 +191,8 @@ ignore = [ "PLR2004", # Just annoying, not really useful "PD011", # Just annoying, not really useful "S101", # assert is often used to satisfy type checking + "TCH001", # Just annoying, not really useful + "TCH003", # Just annoying, not really useful "TD002", # Just annoying, not really useful "TD003", # Just annoying, not really useful "TD004", # Just annoying, not really useful