def _create() -> None:
with suppress(FileExistsError):
os.mkfifo(self._pipe_path)
+ # Should we handle the FileExistsError and check to make
+ # sure the file is indeed a named pipe using os.stat()
+ # and if it isn't then delete and re-create?
await asyncio.to_thread(_create)
content_type=ContentType.from_bit_depth(16), sample_rate=44100, bit_depth=16
)
-BROKEN_RAOP_MODELS = (
- # A recent fw update of newer gen Sonos speakers block RAOP (airplay 1) support,
- # basically rendering our airplay implementation useless on these devices.
+BROKEN_AIRPLAY_MODELS = (
+ # A recent fw update of newer gen Sonos speakers have AirPlay issues,
+ # basically rendering our (both AP2 and RAOP) implementation useless on these devices.
# This list contains the models that are known to have this issue.
# Hopefully the issue won't spread to other models.
("Sonos", "Era 100"),
("Sonos", "Arc Ultra"),
# Samsung has been repeatedly being reported as having issues with AirPlay 1/raop
("Samsung", "*"),
+)
+
+AIRPLAY_2_DEFAULT_MODELS = (
+ # Models that are known to work better with AirPlay 2 protocol instead of RAOP
("Ubiquiti Inc.", "*"),
("Juke Audio", "*"),
)
from zeroconf import IPVersion
from music_assistant.helpers.process import check_output
-from music_assistant.providers.airplay.constants import BROKEN_RAOP_MODELS, StreamingProtocol
+from music_assistant.providers.airplay.constants import (
+ AIRPLAY_2_DEFAULT_MODELS,
+ BROKEN_AIRPLAY_MODELS,
+ StreamingProtocol,
+)
if TYPE_CHECKING:
from zeroconf.asyncio import AsyncServiceInfo
return None
-def is_broken_raop_model(manufacturer: str, model: str) -> bool:
+def is_broken_airplay_model(manufacturer: str, model: str) -> bool:
"""Check if a model is known to have broken RAOP support."""
- for broken_manufacturer, broken_model in BROKEN_RAOP_MODELS:
+ for broken_manufacturer, broken_model in BROKEN_AIRPLAY_MODELS:
if broken_manufacturer in (manufacturer, "*") and broken_model in (model, "*"):
return True
return False
+def is_airplay2_preferred_model(manufacturer: str, model: str) -> bool:
+ """Check if a model is known to work better with AirPlay 2 protocol."""
+ for ap2_manufacturer, ap2_model in AIRPLAY_2_DEFAULT_MODELS:
+ if ap2_manufacturer in (manufacturer, "*") and ap2_model in (model, "*"):
+ return True
+ return False
+
+
async def get_cli_binary(protocol: StreamingProtocol) -> str:
"""Find the correct raop/airplay binary belonging to the platform.
]
passing_output = "cliraop check"
else:
- config_file = os.path.join(os.path.dirname(__file__), "bin", "cliap2.conf")
args = [
cli_path,
"--testrun",
- "--config",
- config_file,
]
returncode, output = await check_output(*args)
RAOP_DISCOVERY_TYPE,
StreamingProtocol,
)
-from .helpers import get_primary_ip_address_from_zeroconf, is_broken_raop_model
+from .helpers import (
+ get_primary_ip_address_from_zeroconf,
+ is_airplay2_preferred_model,
+ is_broken_airplay_model,
+)
from .stream_session import AirPlayStreamSession
if TYPE_CHECKING:
from .provider import AirPlayProvider
-BROKEN_RAOP_WARN = ConfigEntry(
- key="broken_raop",
+BROKEN_AIRPLAY_WARN = ConfigEntry(
+ key="BROKEN_AIRPLAY",
type=ConfigEntryType.ALERT,
default_value=None,
required=False,
- label="This player is known to have broken AirPlay 1 (RAOP) support. "
- "Playback may fail or simply be silent. There is no workaround for this issue at the moment.",
+ label="This player is known to have broken AirPlay support. "
+ "Playback may fail or simply be silent. "
+ "There is no workaround for this issue at the moment. \n"
+ "If you already enforced AirPlay 2 on the player and it remains silent, "
+ "this is one of the known broken models. Only remedy is to nag the manufacturer for a fix.",
)
}
self._attr_volume_level = initial_volume
self._attr_can_group_with = {provider.lookup_key}
- self._attr_enabled_by_default = not is_broken_raop_model(manufacturer, model)
+ self._attr_enabled_by_default = not is_broken_airplay_model(manufacturer, model)
@cached_property
def protocol(self) -> StreamingProtocol:
ConfigEntry(
key=CONF_AIRPLAY_PROTOCOL,
type=ConfigEntryType.INTEGER,
- default_value=StreamingProtocol.RAOP.value,
required=False,
label="AirPlay version to use for streaming",
description="AirPlay version 1 protocol uses RAOP.\n"
ConfigValueOption("AirPlay 1 (RAOP)", StreamingProtocol.RAOP.value),
ConfigValueOption("AirPlay 2", StreamingProtocol.AIRPLAY2.value),
],
- hidden=True,
+ default_value=StreamingProtocol.AIRPLAY2.value
+ if is_airplay2_preferred_model(
+ self.device_info.manufacturer, self.device_info.model
+ )
+ else StreamingProtocol.RAOP.value,
),
ConfigEntry(
key=CONF_ENCRYPTION,
type=ConfigEntryType.BOOLEAN,
- default_value=False,
+ default_value=True,
label="Enable encryption",
description="Enable encrypted communication with the player, "
- "some (3rd party) players require this.",
+ "some (3rd party) players require to disable this.",
category="airplay",
+ depends_on=CONF_AIRPLAY_PROTOCOL,
+ depends_on_value=StreamingProtocol.RAOP.value,
),
ConfigEntry(
key=CONF_ALAC_ENCODE,
description="Save some network bandwidth by sending the audio as "
"(lossless) ALAC at the cost of a bit CPU.",
category="airplay",
+ depends_on=CONF_AIRPLAY_PROTOCOL,
+ depends_on_value=StreamingProtocol.RAOP.value,
),
CONF_ENTRY_SYNC_ADJUST,
ConfigEntry(
"Recommended: 1000ms for stable playback.",
category="airplay",
range=(500, 2000),
+ depends_on=CONF_AIRPLAY_PROTOCOL,
+ depends_on_value=StreamingProtocol.RAOP.value,
),
# airplay has fixed sample rate/bit depth so make this config entry static and hidden
create_sample_rates_config_entry(
"Enable this option to ignore these reports."
),
category="airplay",
+ depends_on=CONF_AIRPLAY_PROTOCOL,
+ depends_on_value=StreamingProtocol.RAOP.value,
),
]
- if is_broken_raop_model(self.device_info.manufacturer, self.device_info.model):
- base_entries.insert(-1, BROKEN_RAOP_WARN)
+ if is_broken_airplay_model(self.device_info.manufacturer, self.device_info.model):
+ base_entries.insert(-1, BROKEN_AIRPLAY_WARN)
return base_entries
"""Send PLAY (unpause) command to player."""
async with self._lock:
if self.stream and self.stream.running:
- await self.stream.send_cli_command("ACTION=PLAY")
+ await self.stream.send_cli_command("ACTION=PLAY\n")
async def pause(self) -> None:
"""Send PAUSE command to player."""
async with self._lock:
if not self.stream or not self.stream.running:
return
- await self.stream.send_cli_command("ACTION=PAUSE")
+ await self.stream.send_cli_command("ACTION=PAUSE\n")
async def play_media(self, media: PlayerMedia) -> None:
"""Handle PLAY MEDIA on given player."""
from __future__ import annotations
-import asyncio
import logging
-import os
-import platform
from music_assistant_models.enums import PlaybackState
from music_assistant_models.errors import PlayerCommandFailed
AIRPLAY2_MIN_LOG_LEVEL,
CONF_READ_AHEAD_BUFFER,
)
-from music_assistant.providers.airplay.helpers import get_cli_binary, get_ntp_timestamp
+from music_assistant.providers.airplay.helpers import get_cli_binary
from ._protocol import AirPlayProtocol
and we can send some interactive commands using another named pipe.
"""
- _stderr_reader_task: asyncio.Task[None] | None = None
-
- async def get_ntp(self) -> int:
- """Get current NTP timestamp."""
- # this can probably be removed now that we already get the ntp
- # in python (within the stream session start)
- return get_ntp_timestamp()
-
@property
def _cli_loglevel(self) -> int:
"""
Ensures that minimum level required for required cliap2 stderr output is respected.
"""
- force_verbose: bool = False # just for now
mass_level: int = 0
match self.prov.logger.level:
case logging.CRITICAL:
mass_level = 4
if self.prov.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
mass_level = 5
- if force_verbose:
- mass_level = 5 # always use max log level for now to capture all stderr output
return max(mass_level, AIRPLAY2_MIN_LOG_LEVEL)
async def start(self, start_ntp: int, skip: int = 0) -> None:
# cliap2 is the binary that handles the actual streaming to the player
# this binary leverages from the AirPlay2 support in owntones
# https://github.com/music-assistant/cliairplay
+
cli_args = [
cli_binary,
- "--config",
- os.path.join(os.path.dirname(__file__), "bin", "cliap2.conf"),
"--name",
self.player.display_name,
"--hostname",
"--loglevel",
str(self._cli_loglevel),
"--pipe",
- self.audio_named_pipe,
+ self.audio_pipe.path,
+ "--command_pipe",
+ self.commands_pipe.path,
]
+
self.player.logger.debug(
"Starting cliap2 process for player %s with args: %s",
player_id,
cli_args,
)
- self._cli_proc = AsyncProcess(cli_args, stdin=True, stderr=True, name="cliap2")
- if platform.system() == "Darwin":
- os.environ["DYLD_LIBRARY_PATH"] = "/usr/local/lib"
+ self._cli_proc = AsyncProcess(cli_args, stdin=False, stderr=True, name="cliap2")
await self._cli_proc.start()
# read up to first num_lines lines of stderr to get the initial status
num_lines: int = 50
self.player.logger.debug(line)
if f"airplay: Adding AirPlay device '{self.player.display_name}'" in line:
self.player.logger.info("AirPlay device connected. Starting playback.")
- self._started.set()
- # Open pipes now that cliraop is ready
- await self._open_pipes()
break
if f"The AirPlay 2 device '{self.player.display_name}' failed" in line:
raise PlayerCommandFailed("Cannot connect to AirPlay device")
# start reading the stderr of the cliap2 process from another task
- self._stderr_reader_task = self.mass.create_task(self._stderr_reader())
+ self._cli_proc.attach_stderr_reader(self.mass.create_task(self._stderr_reader()))
async def _stderr_reader(self) -> None:
"""Monitor stderr for the running CLIap2 process."""
player = self.player
- queue = self.mass.players.get_active_queue(player)
logger = player.logger
- lost_packets = 0
if not self._cli_proc:
return
async for line in self._cli_proc.iter_stderr():
- # TODO @bradkeifer make cliap2 work this way
- if "elapsed milliseconds:" in line:
- # this is received more or less every second while playing
- # millis = int(line.split("elapsed milliseconds: ")[1])
- # self.player.elapsed_time = (millis / 1000) - self.elapsed_time_correction
- # self.player.elapsed_time_last_updated = time.time()
- # NOTE: Metadata is now handled at the session level
- pass
- if "set pause" in line or "Pause at" in line:
+ if "Pause at" in line:
player.set_state_from_stream(state=PlaybackState.PAUSED)
- if "Restarted at" in line or "restarting w/ pause" in line:
+ if "Restarted at" in line:
player.set_state_from_stream(state=PlaybackState.PLAYING)
- if "restarting w/o pause" in line:
+ if "Starting at" in line:
# streaming has started
player.set_state_from_stream(state=PlaybackState.PLAYING, elapsed_time=0)
- if "lost packet out of backlog" in line:
- lost_packets += 1
- if lost_packets == 100 and queue:
+ if "put delay detected" in line:
+ if "resetting all outputs" in line:
logger.error("High packet loss detected, restarting playback...")
- self.mass.create_task(self.mass.player_queues.resume(queue.queue_id, False))
+ self.mass.create_task(self.mass.players.cmd_resume(self.player.player_id))
else:
logger.warning("Packet loss detected!")
if "end of stream reached" in line: