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)
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
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
]
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
"-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
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":
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:
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":
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:
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)
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"
# 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 "
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,
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,
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)
# 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
)
_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
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"]}'}
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)
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(
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: