Skip to content

Commit ef2f4cd

Browse files
authored
Fix some small issues with AirPlay and sendspin bridging (music-assistant#3313)
1 parent 966d104 commit ef2f4cd

6 files changed

Lines changed: 132 additions & 106 deletions

File tree

music_assistant/providers/airplay/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ class StreamingProtocol(IntEnum):
4141
# Maximum output buffer duration permitted.
4242
AIRPLAY_OUTPUT_BUFFER_MAX_DURATION_MS: Final[int] = 5000
4343
AIRPLAY2_MIN_LOG_LEVEL: Final[int] = 3 # Min loglevel to ensure stderr output contains what we need
44-
AIRPLAY2_CONNECT_TIME_MS: Final[int] = 2000 # Time in ms to allow AirPlay2 device to connect
45-
RAOP_CONNECT_TIME_MS: Final[int] = 1000 # Time in ms to allow RAOP device to connect
44+
AIRPLAY2_CONNECT_TIME_MS: Final[int] = 4000 # Time in ms to allow AirPlay2 device to connect
45+
RAOP_CONNECT_TIME_MS: Final[int] = 1500 # Time in ms to allow RAOP device to connect
4646

4747
# Per-protocol credential storage keys
4848
CONF_RAOP_CREDENTIALS: Final[str] = "raop_credentials"

music_assistant/providers/airplay/player.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
2121

2222
from .constants import (
23+
AIRPLAY2_CONNECT_TIME_MS,
2324
AIRPLAY_DISCOVERY_TYPE,
2425
AIRPLAY_FLOW_PCM_FORMAT,
2526
AIRPLAY_OUTPUT_BUFFER_DEFAULT_DURATION_MS,
@@ -43,6 +44,7 @@
4344
FALLBACK_VOLUME,
4445
LEGACY_PAIRING_BIT,
4546
PIN_REQUIRED,
47+
RAOP_CONNECT_TIME_MS,
4648
RAOP_DISCOVERY_TYPE,
4749
StreamingProtocol,
4850
)
@@ -156,6 +158,14 @@ def output_buffer_duration_ms(self) -> int:
156158
self.config.get_value(CONF_AIRPLAY_LATENCY, AIRPLAY_OUTPUT_BUFFER_DEFAULT_DURATION_MS),
157159
)
158160

161+
@property
162+
def wait_start(self) -> int:
163+
"""Get the time in ms to allow device to connect before starting stream."""
164+
# TODO: make this value configurable ?
165+
if self.protocol == StreamingProtocol.AIRPLAY2:
166+
return AIRPLAY2_CONNECT_TIME_MS
167+
return RAOP_CONNECT_TIME_MS
168+
159169
async def get_config_entries(
160170
self,
161171
action: str | None = None,
@@ -293,12 +303,8 @@ def _get_credentials_key(self, protocol: StreamingProtocol) -> str:
293303

294304
def _get_protocol_for_config_value(self, config_option: int) -> StreamingProtocol:
295305
if config_option == StreamingProtocol.AIRPLAY2:
296-
if not self.airplay_discovery_info:
297-
raise ValueError("No AirPlay service found for this player")
298306
return StreamingProtocol.AIRPLAY2
299307
if config_option == StreamingProtocol.RAOP:
300-
if not self.raop_discovery_info:
301-
raise ValueError("No RAOP service found for this player")
302308
return StreamingProtocol.RAOP
303309
# automatic selection
304310
if self.airplay_discovery_info and is_airplay2_preferred_model(
@@ -497,6 +503,14 @@ async def stop(self) -> None:
497503
if self.stream and self.stream.session:
498504
# forward stop to the entire stream session
499505
await self.stream.session.stop()
506+
elif cast("AirPlayProvider", self.provider).bridge_manager.stop_streaming(self.player_id):
507+
# Sendspin bridge active: trigger full bridge cleanup
508+
# which stops streaming, kills the CLI, and cancels writer tasks
509+
pass
510+
elif self.stream and self.stream.running:
511+
# Fallback: stop protocol directly
512+
await self.stream.stop(force=True)
513+
self.stream = None
500514
self._attr_current_media = None
501515
self.update_state()
502516

music_assistant/providers/airplay/protocols/_protocol.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,16 +94,18 @@ async def stop(self, force: bool = False) -> None:
9494
"""
9595
# always send stop command first
9696
await self.send_cli_command("ACTION=STOP")
97-
if self._cli_proc:
98-
await self._cli_proc.write_eof()
9997
self._stopped = True
10098
await self.commands_pipe.remove()
10199
if force:
100+
# Kill immediately - skip write_eof() as it can block indefinitely
101+
# when the CLI stops reading from stdin after receiving STOP.
102102
if self._cli_proc and not self._cli_proc.closed:
103103
await self._cli_proc.kill()
104-
elif self._cli_proc and not self._cli_proc.closed:
105-
await self._cli_proc.close()
106-
if not force:
104+
else:
105+
if self._cli_proc:
106+
await self._cli_proc.write_eof()
107+
if self._cli_proc and not self._cli_proc.closed:
108+
await self._cli_proc.close()
107109
self.player.set_state_from_stream(state=PlaybackState.IDLE, elapsed_time=0)
108110

109111
async def write_audio(self, data: bytes) -> None:

music_assistant/providers/airplay/provider.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ class AirPlayProvider(PlayerProvider):
4343
_dacp_info: AsyncServiceInfo
4444
_bridge_manager: SendspinBridgeManager
4545

46+
@property
47+
def bridge_manager(self) -> SendspinBridgeManager:
48+
"""Return the Sendspin bridge manager."""
49+
return self._bridge_manager
50+
4651
async def handle_async_init(self) -> None:
4752
"""Handle async initialization of the provider."""
4853
# Initialize Sendspin bridge manager for protocol linking
@@ -126,6 +131,10 @@ async def _setup_player(
126131
self, player_id: str, display_name: str, discovery_info: AsyncServiceInfo
127132
) -> None:
128133
"""Handle setup of a new player that is discovered using mdns."""
134+
# return early if player is disabled in config
135+
if not self.mass.config.get_raw_player_config_value(player_id, "enabled", True):
136+
self.logger.debug("Ignoring %s in discovery as it is disabled.", display_name)
137+
return
129138
raop_discovery_info: AsyncServiceInfo | None = None
130139
airplay_discovery_info: AsyncServiceInfo | None = None
131140
if discovery_info.type == RAOP_DISCOVERY_TYPE:
@@ -139,18 +148,26 @@ async def _setup_player(
139148
AIRPLAY_DISCOVERY_TYPE,
140149
discovery_info.name.split("@")[-1].replace("_raop", "_airplay"),
141150
)
142-
await airplay_discovery_info.async_request(self.mass.aiozc.zeroconf, 3000)
151+
if not await airplay_discovery_info.async_request(self.mass.aiozc.zeroconf, 3000):
152+
airplay_discovery_info = None
143153
else:
144154
# AirPlay service discovered
145155
self.logger.debug("Discovered AirPlay service for %s", display_name)
146156
airplay_discovery_info = discovery_info
157+
# also try to get the raop info if available
158+
raop_discovery_info = AsyncServiceInfo(
159+
RAOP_DISCOVERY_TYPE,
160+
discovery_info.name.split("@")[-1].replace("_airplay", "_raop"),
161+
)
162+
if not await raop_discovery_info.async_request(self.mass.aiozc.zeroconf, 3000):
163+
raop_discovery_info = None
147164

148165
if airplay_discovery_info:
149166
manufacturer, model = get_model_info(airplay_discovery_info)
150167
elif raop_discovery_info:
151168
manufacturer, model = get_model_info(raop_discovery_info)
152169
else:
153-
manufacturer, model = "Unknown", "Unknown"
170+
return # should not happen, but guard just in case
154171

155172
address = get_primary_ip_address_from_zeroconf(discovery_info)
156173
if not address:
@@ -174,12 +191,6 @@ async def _setup_player(
174191
if discovery_info.port in receiver_ports:
175192
return
176193

177-
if not self.mass.config.get_raw_player_config_value(player_id, "enabled", True):
178-
self.logger.debug("Ignoring %s in discovery as it is disabled.", display_name)
179-
return
180-
if not discovery_info:
181-
return # should not happen, but guard just in case
182-
183194
# if we reach this point, all preflights are ok and we can create the player
184195
self.logger.debug("Discovered AirPlay device %s on %s", display_name, address)
185196

0 commit comments

Comments
 (0)