Skip to content

Commit

Permalink
Small fixes (#1135)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelveldt authored Mar 12, 2024
1 parent 969af90 commit 601ff67
Show file tree
Hide file tree
Showing 11 changed files with 89 additions and 53 deletions.
17 changes: 13 additions & 4 deletions music_assistant/server/controllers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,15 @@ async def get_provider_configs(
self,
provider_type: ProviderType | None = None,
provider_domain: str | None = None,
include_values: bool = False,
) -> list[ProviderConfig]:
"""Return all known provider configurations, optionally filtered by ProviderType."""
raw_values: dict[str, dict] = self.get(CONF_PROVIDERS, {})
prov_entries = {x.domain for x in self.mass.get_provider_manifests()}
return [
await self.get_provider_config(prov_conf["instance_id"])
if include_values
else ProviderConfig.parse([], prov_conf)
for prov_conf in raw_values.values()
if (provider_type is None or prov_conf["type"] == provider_type)
and (provider_domain is None or prov_conf["domain"] == provider_domain)
Expand Down Expand Up @@ -317,11 +320,15 @@ async def reload_provider(self, instance_id: str) -> None:
await self._load_provider_config(config)

@api_command("config/players")
async def get_player_configs(self, provider: str | None = None) -> list[PlayerConfig]:
async def get_player_configs(
self, provider: str | None = None, include_values: bool = False
) -> list[PlayerConfig]:
"""Return all known player configurations, optionally filtered by provider domain."""
available_providers = {x.instance_id for x in self.mass.providers}
return [
await self.get_player_config(raw_conf["player_id"])
if include_values
else PlayerConfig.parse([], raw_conf)
for raw_conf in list(self.get(CONF_PLAYERS, {}).values())
# filter out unavailable providers
if raw_conf["provider"] in available_providers
Expand Down Expand Up @@ -501,12 +508,14 @@ async def create_default_provider_config(self, provider_domain: str) -> None:
self.set(conf_key, default_config.to_raw())

@api_command("config/core")
async def get_core_configs(
self,
) -> list[CoreConfig]:
async def get_core_configs(self, include_values: bool = False) -> list[CoreConfig]:
"""Return all core controllers config options."""
return [
await self.get_core_config(core_controller)
if include_values
else CoreConfig.parse(
[], self.get(f"{CONF_CORE}/{core_controller}", {"domain": core_controller})
)
for core_controller in CONFIGURABLE_CORE_CONTROLLERS
]

Expand Down
2 changes: 1 addition & 1 deletion music_assistant/server/controllers/players.py
Original file line number Diff line number Diff line change
Expand Up @@ -953,7 +953,7 @@ async def _sync_syncgroup(self, player_id: str) -> None:

async def _register_syncgroups(self) -> None:
"""Register all (virtual/fake) syncgroup players."""
player_configs = await self.mass.config.get_player_configs()
player_configs = await self.mass.config.get_player_configs(include_values=True)
for player_config in player_configs:
if not player_config.player_id.startswith(SYNCGROUP_PREFIX):
continue
Expand Down
1 change: 0 additions & 1 deletion music_assistant/server/controllers/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -937,7 +937,6 @@ async def _get_player_ffmpeg_args(
"-i",
"-",
]
input_args += ["-metadata", 'title="Music Assistant"']
# select output args
if output_format.content_type == ContentType.FLAC:
# set compression level to 0 to prevent issues with cast players
Expand Down
55 changes: 33 additions & 22 deletions music_assistant/server/providers/airplay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,44 +199,46 @@ async def init_cliraop(self, start_ntp: int) -> None:
player_id = self.airplay_player.player_id
mass_player = self.mass.players.get(player_id)
if self.mass.config.get_raw_player_config_value(player_id, CONF_ENCRYPTION, False):
extra_args += ["-e"]
extra_args += ["-encrypt"]
if self.mass.config.get_raw_player_config_value(player_id, CONF_ALAC_ENCODE, True):
extra_args += ["-a"]
extra_args += ["-alac"]
if "airport" in mass_player.device_info.model.lower():
# enforce auth on airport express
extra_args += ["-auth"]
for prop in ("et", "md", "am", "pk", "pw"):
if prop_value := self.airplay_player.discovery_info.decoded_properties.get(prop):
extra_args += [f"-{prop}", prop_value]

sync_adjust = self.mass.config.get_raw_player_config_value(player_id, CONF_SYNC_ADJUST, 0)
if device_password := self.mass.config.get_raw_player_config_value(
player_id, CONF_PASSWORD, None
):
# NOTE: This may not work as we might need to do
# some fancy hashing with the plain password first?!
extra_args += ["-P", device_password]
extra_args += ["-password", device_password]
if self.prov.log_level == "DEBUG":
extra_args += ["-d", "5"]
extra_args += ["-debug", "5"]
elif self.prov.log_level == "VERBOSE":
extra_args += ["-d", "10"]
extra_args += ["-debug", "10"]

args = [
self.prov.cliraop_bin,
"-n",
"-ntpstart",
str(start_ntp),
"-p",
"-port",
str(self.airplay_player.discovery_info.port),
"-w",
"-wait",
str(2000 - sync_adjust),
"-v",
"-volume",
str(mass_player.volume_level),
*extra_args,
"-dacp",
self.prov.dacp_id,
"-ar",
"-activeremote",
self.active_remote_id,
"-md",
self.airplay_player.discovery_info.decoded_properties["md"],
"-et",
self.airplay_player.discovery_info.decoded_properties["et"],
str(self.airplay_player.discovery_info.parsed_addresses()[0]),
"-udn",
str(self.airplay_player.discovery_info.name),
self.airplay_player.address,
"-",
]
if platform.system() == "Darwin":
Expand Down Expand Up @@ -288,12 +290,13 @@ def send_data():
self.airplay_player.logger.debug("sending command %s", command)
await self.mass.create_task(send_data)

async def _log_watcher(self) -> None:
async def _log_watcher(self) -> None: # noqa: PLR0915
"""Monitor stderr for the running CLIRaop process."""
airplay_player = self.airplay_player
mass_player = self.mass.players.get(airplay_player.player_id)
logger = airplay_player.logger
airplay_player.logger.debug("Starting log watcher task...")
lost_packets = 0
async for line in self._cliraop_proc.stderr:
line = line.decode().strip() # noqa: PLW2901
if not line:
Expand Down Expand Up @@ -328,7 +331,13 @@ async def _log_watcher(self) -> None:
self.mass.players.update(airplay_player.player_id)
continue
if "lost packet out of backlog" in line:
logger.warning(line)
lost_packets += 1
if lost_packets == 10:
logger.warning("Packet loss detected, resuming playback...")
queue = self.mass.player_queues.get_active_queue(mass_player.player_id)
await self.mass.player_queues.resume(queue.queue_id)
else:
logger.debug(line)
continue
# debug log everything else
if self.prov.log_level == "VERBOSE":
Expand Down Expand Up @@ -950,8 +959,10 @@ async def _handle_dacp_request( # noqa: PLR0915
elif "device-prevent-playback=1" in path:
# device switched to another source (or is powered off)
if active_stream := airplay_player.active_stream:
active_stream.prevent_playback = True
self.mass.create_task(self.monitor_prevent_playback(player_id))
# ignore this if we just started playing to prevent false positives
if mass_player.elapsed_time > 2 and mass_player.state == PlayerState.PLAYING:
active_stream.prevent_playback = True
self.mass.create_task(self.monitor_prevent_playback(player_id))
elif "device-prevent-playback=0" in path:
# device reports that its ready for playback again
if active_stream := airplay_player.active_stream:
Expand Down Expand Up @@ -1040,12 +1051,12 @@ async def monitor_prevent_playback(self, player_id: str):
count += 1
if not (airplay_player := self._players.get(player_id)):
return
if not airplay_player.active_stream:
if not (active_stream := airplay_player.active_stream):
return
if airplay_player.active_stream.start_ntp != prev_ntp:
if active_stream.start_ntp != prev_ntp:
# checksum
return
if not airplay_player.active_stream.prevent_playback:
if not active_stream.prevent_playback:
return
await asyncio.sleep(0.25)

Expand Down
Binary file modified music_assistant/server/providers/airplay/bin/cliraop-linux-aarch64
Binary file not shown.
Binary file modified music_assistant/server/providers/airplay/bin/cliraop-linux-x86_64
Binary file not shown.
Binary file modified music_assistant/server/providers/airplay/bin/cliraop-macos-arm64
Binary file not shown.
46 changes: 25 additions & 21 deletions music_assistant/server/providers/slimproto/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ class SyncPlayPoint:
diff: int


CONF_CLI_TELNET = "cli_telnet"
CONF_CLI_JSON = "cli_json"
CONF_CLI_TELNET_PORT = "cli_telnet_port"
CONF_CLI_JSON_PORT = "cli_json_port"
CONF_DISCOVERY = "discovery"
CONF_DISPLAY = "display"
CONF_VISUALIZATION = "visualization"
Expand Down Expand Up @@ -155,32 +155,32 @@ async def get_config_entries(
# ruff: noqa: ARG001
return (
ConfigEntry(
key=CONF_CLI_TELNET,
type=ConfigEntryType.BOOLEAN,
default_value=True,
label="Enable classic Squeezebox Telnet CLI",
key=CONF_CLI_TELNET_PORT,
type=ConfigEntryType.INTEGER,
default_value=9090,
label="Classic Squeezebox CLI Port",
description="Some slimproto based players require the presence of the telnet CLI "
" to request more information. "
"By default this Telnet CLI is hosted on port 9090 but another port will be chosen if "
"that port is already taken. \n\n"
" to request more information. \n\n"
"By default this CLI is hosted on port 9090 but some players also accept "
"a different port. Set to 0 to disable this functionality.\n\n"
"Commands allowed on this interface are very limited and just enough to satisfy "
"player compatibility, so security risks are minimized to practically zero."
"You may safely disable this option if you have no players that rely on this feature "
"or you dont care about the additional metadata.",
advanced=True,
),
ConfigEntry(
key=CONF_CLI_JSON,
type=ConfigEntryType.BOOLEAN,
default_value=True,
label="Enable JSON-RPC API",
key=CONF_CLI_JSON_PORT,
type=ConfigEntryType.INTEGER,
default_value=9000,
label="JSON-RPC CLI/API Port",
description="Some slimproto based players require the presence of the JSON-RPC "
"API from LMS to request more information. For example to fetch the album cover "
"and other metadata. "
"and other metadata. \n\n"
"This JSON-RPC API is compatible with Logitech Media Server but not all commands "
"are implemented. Just enough to satisfy player compatibility. \n\n"
"This API is hosted on the webserver responsible for streaming to players and thus "
"accessible on your local network but security impact should be minimal. "
"By default this JSON CLI is hosted on port 9000 but most players also accept "
"it on a different port. Set to 0 to disable this functionality.\n\n"
"You may safely disable this option if you have no players that rely on this feature "
"or you dont care about the additional metadata.",
advanced=True,
Expand Down Expand Up @@ -228,12 +228,12 @@ async def handle_async_init(self) -> None:
self._do_not_resync_before = {}
self._resync_handle: asyncio.TimerHandle | None = None
control_port = self.config.get_value(CONF_PORT)
enable_telnet = self.config.get_value(CONF_CLI_TELNET)
enable_json = self.config.get_value(CONF_CLI_JSON)
telnet_port = self.config.get_value(CONF_CLI_TELNET_PORT)
json_port = self.config.get_value(CONF_CLI_JSON_PORT)
logging.getLogger("aioslimproto").setLevel(self.logger.level)
self.slimproto = SlimServer(
cli_port=0 if enable_telnet else None,
cli_port_json=0 if enable_json else None,
cli_port=telnet_port or None,
cli_port_json=json_port or None,
ip_address=self.mass.streams.publish_ip,
name="Music Assistant",
control_port=control_port,
Expand Down Expand Up @@ -712,6 +712,10 @@ async def _handle_player_cli_event(self, slimplayer: SlimClient, event: SlimEven
self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled)
slimplayer.extra_data["playlist shuffle"] = int(queue.shuffle_enabled)
slimplayer.signal_update()
elif event.data == "button jump_fwd":
await self.mass.player_queues.next(queue.queue_id)
elif event.data == "button jump_rew":
await self.mass.player_queues.previous(queue.queue_id)
elif event.data.startswith("time "):
# seek request
_, param = event.data.split(" ", 1)
Expand Down Expand Up @@ -825,7 +829,7 @@ async def _handle_buffer_ready(self, slimplayer: SlimClient) -> None:
# all child's ready (or timeout) - start play
async with asyncio.TaskGroup() as tg:
for _client in self._get_sync_clients(player.player_id):
timestamp = _client.jiffies + 200
timestamp = _client.jiffies + 500
sync_delay = self.mass.config.get_raw_player_config_value(
_client.player_id, CONF_SYNC_ADJUST, 0
)
Expand Down
15 changes: 13 additions & 2 deletions music_assistant/server/providers/spotify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,12 @@ class SpotifyProvider(MusicProvider):
_auth_token: str | None = None
_sp_user: str | None = None
_librespot_bin: str | None = None
# rate limiter needs to be specified on provider-level,
# so make it an instance attribute
_throttler = Throttler(rate_limit=1, period=1)

async def handle_async_init(self) -> None:
"""Handle async initialization of the provider."""
self._throttler = Throttler(rate_limit=1, period=1)
self._cache_dir = CACHE_DIR
self._ap_workaround = False
# try to get a token, raise if that fails
Expand Down Expand Up @@ -733,11 +735,12 @@ async def _get_all_items(self, endpoint, key="items", **kwargs) -> list[dict]:
break
return all_items

async def _get_data(self, endpoint, tokeninfo: dict | None = None, **kwargs):
async def _get_data(self, endpoint, **kwargs):
"""Get data from api."""
url = f"https://api.spotify.com/v1/{endpoint}"
kwargs["market"] = "from_token"
kwargs["country"] = "from_token"
tokeninfo = kwargs.pop("tokeninfo", None)
if tokeninfo is None:
tokeninfo = await self.login()
headers = {"Authorization": f'Bearer {tokeninfo["accessToken"]}'}
Expand All @@ -748,6 +751,14 @@ async def _get_data(self, endpoint, tokeninfo: dict | None = None, **kwargs):
async with self.mass.http_session.get(
url, headers=headers, params=kwargs, ssl=False, timeout=120
) as response:
# handle spotify rate limiter
if response.status == 429:
backoff_time = int(response.headers["Retry-After"])
self.logger.debug(
"Waiting %s seconds on Spotify rate limiter", backoff_time
)
await asyncio.sleep(backoff_time)
return await self._get_data(endpoint, **kwargs)
# get text before json so we can log the body in case of errors
result = await response.text()
result = json_loads(result)
Expand Down
4 changes: 3 additions & 1 deletion music_assistant/server/providers/ugp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,9 @@ async def create_group(self, name: str, members: list[str]) -> Player:

async def _register_all_players(self) -> None:
"""Register all (virtual/fake) group players in the Player controller."""
player_configs = await self.mass.config.get_player_configs(self.instance_id)
player_configs = await self.mass.config.get_player_configs(
self.instance_id, include_values=True
)
for player_config in player_configs:
members = player_config.get_value(CONF_GROUP_MEMBERS)
self._register_group_player(
Expand Down
2 changes: 1 addition & 1 deletion music_assistant/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ async def load_provider(prov_conf: ProviderConfig) -> None:
self.config.set(f"{CONF_PROVIDERS}/{prov_conf.instance_id}/last_error", str(exc))

# load all configured (and enabled) providers
prov_configs = await self.config.get_provider_configs()
prov_configs = await self.config.get_provider_configs(include_values=True)
async with asyncio.TaskGroup() as tg:
for prov_conf in prov_configs:
if not prov_conf.enabled:
Expand Down

0 comments on commit 601ff67

Please sign in to comment.