Skip to content

Added AriaCast Receiver plugin for Music Assistant#3061

Draft
AirPlr wants to merge 54 commits intomusic-assistant:devfrom
AirPlr:dev
Draft

Added AriaCast Receiver plugin for Music Assistant#3061
AirPlr wants to merge 54 commits intomusic-assistant:devfrom
AirPlr:dev

Conversation

@AirPlr
Copy link

@AirPlr AirPlr commented Jan 30, 2026

  • Implemented AriaCast Receiver plugin to stream audio from Android devices to Music Assistant players.
  • Created configuration classes for audio and server settings.
  • Developed metadata handling for AriaCast streams.
  • Implemented UDP discovery and WebSocket server for audio and metadata streaming.
  • Added helper functions for local IP retrieval and artwork downloading.
  • Included SVG icon for the plugin.
  • Updated manifest.json with documentation link and requirements.

- Implemented AriaCast Receiver plugin to stream audio from Android devices to Music Assistant players.
- Added README.md with features, installation, configuration, and usage instructions.
- Created configuration classes for audio and server settings.
- Developed metadata handling for AriaCast streams.
- Implemented UDP discovery and WebSocket server for audio and metadata streaming.
- Added helper functions for local IP retrieval and artwork downloading.
- Included SVG icon for the plugin.
- Updated manifest.json with documentation link and requirements.
@AirPlr AirPlr marked this pull request as draft January 30, 2026 22:56
@AirPlr AirPlr marked this pull request as ready for review January 30, 2026 22:58
@OzGav
Copy link
Contributor

OzGav commented Jan 31, 2026

You need to run pre-commit before you push the commit. There are a ton of mypy errors….. I have marked this as draft. Please mark as ready for review when these are fixed

@OzGav OzGav marked this pull request as draft January 31, 2026 01:42
@AirPlr AirPlr marked this pull request as ready for review January 31, 2026 09:19
@OzGav
Copy link
Contributor

OzGav commented Jan 31, 2026

I am not sure how you are creating this provider. You can’t be running pre-commit as there are 107 errors!

@AirPlr
Copy link
Author

AirPlr commented Jan 31, 2026

I am not sure how you are creating this provider. You can’t be running pre-commit as there are 107 errors!

🐶 Ruff Linter...........................................................Passed

  • hook id: ruff-check
  • duration: 0.14s

All checks passed!

🐶 Ruff Formatter........................................................Passed

  • hook id: ruff-format
  • duration: 0.06s

399 files left unchanged

🐍 Check Python AST......................................................Passed

  • hook id: check-ast
  • duration: 0.37s
    🔠 Check for case conflicts..............................................Passed
  • hook id: check-case-conflict
  • duration: 0.26s
    ℹ️ Check docstring is first.............................................Passed
  • hook id: check-docstring-first
  • duration: 0.31s
    🧐 Check that executables have shebangs..................................Passed
  • hook id: check-executables-have-shebangs
  • duration: 0.14s
    { Check JSON files......................................................Passed
  • hook id: check-json
  • duration: 0.25s
    💥 Check for merge conflicts.............................................Passed
  • hook id: check-merge-conflict
  • duration: 0.24s
    🔗 Check for broken symlinks.........................(no files to check)Skipped
  • hook id: check-symlinks
    ✅ Check TOML files......................................................Passed
  • hook id: check-toml
  • duration: 0.1s
    ✅ Check code for common misspellings....................................Passed
  • hook id: codespell
  • duration: 0.48s
    🕵️ Detect Private Keys...................................................Passed
  • hook id: detect-private-key
  • duration: 0.23s
    ⮐ Fix End of Files.......................................................Passed
  • hook id: end-of-file-fixer
  • duration: 0.22s
    🛑 Don't commit to stable branch.........................................Passed
  • hook id: no-commit-to-branch
  • duration: 0.12s
    ✄ Trim Trailing Whitespace...............................................Passed
  • hook id: trailing-whitespace
  • duration: 0.23s
    mypy.....................................................................Passed
  • hook id: mypy
  • duration: 1.19s

Success: no issues found in 414 source files

@AirPlr AirPlr requested a review from OzGav February 3, 2026 20:08
Copilot AI review requested due to automatic review settings February 5, 2026 05:52
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new AriaCast Receiver plugin that enables Music Assistant to receive PCM audio streams from Android devices over WebSocket. The implementation provides UDP-based device discovery, WebSocket endpoints for audio/control/metadata/stats, and integration with Music Assistant's player system.

Changes:

  • Implemented WebSocket server with endpoints for audio streaming, metadata updates, playback control, and statistics
  • Added UDP discovery protocol for device announcement on the local network
  • Created configuration and metadata handling classes with support for both camelCase and snake_case field naming

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 36 comments.

Show a summary per file
File Description
__init__.py Main plugin provider with WebSocket server, UDP discovery, audio buffering, and player management
metadata.py Metadata handler supporting track info, artwork, duration, and playback position
config.py Configuration dataclasses for audio parameters and server settings
helpers.py Helper function for local IP detection (currently unused)
manifest.json Plugin manifest with metadata and requirements
icon.svg Plugin icon graphic

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 196 to 197
self.max_frames = 50 # 1 second buffer
self.frame_queue: deque[bytes] = deque(maxlen=self.max_frames)
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The frame queue has a maximum size of 50 frames (1 second buffer), but there's no backpressure mechanism or notification to the audio client when the queue is full. The deque will silently drop the oldest frames when full. This could cause audio discontinuities if the consumer (get_audio_stream) is slower than the producer. Consider either implementing backpressure (e.g., slowing down the WebSocket receive rate) or logging warnings when frames are being dropped due to buffer overflow.

Copilot uses AI. Check for mistakes.
yield frame
else:
# No data available, wait a bit to avoid busy loop
await asyncio.sleep(0.005)
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_audio_stream method yields frames from the queue with a 5ms sleep when the queue is empty. This busy-waiting pattern with a short sleep could lead to high CPU usage. Consider using an asyncio.Event or asyncio.Queue with proper async waiting instead of polling with sleep. This would be more efficient and responsive.

Suggested change
await asyncio.sleep(0.005)
await asyncio.sleep(0.05)

Copilot uses AI. Check for mistakes.
Comment on lines 23 to 37
SERVER_NAME: str = "AriaCast Speaker"
VERSION: str = "1.0"
PLATFORM: str = "MusicAssistant"
CODECS: list[str] | None = None
DISCOVERY_PORT: int = 12888
STREAMING_PORT: int = 12889
HOST: str = "0.0.0.0"
AUDIO: AudioConfig | None = None

def __post_init__(self) -> None:
"""Initialize default values after instantiation."""
if self.CODECS is None:
self.CODECS = ["PCM"]
if self.AUDIO is None:
self.AUDIO = AudioConfig()
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, the ServerConfig dataclass uses uppercase field names (e.g., SERVER_NAME, VERSION, PLATFORM, etc.) which deviates from Python naming conventions. These appear to be configuration values rather than constants, and should use lowercase or snake_case naming (e.g., server_name, version, platform).

Suggested change
SERVER_NAME: str = "AriaCast Speaker"
VERSION: str = "1.0"
PLATFORM: str = "MusicAssistant"
CODECS: list[str] | None = None
DISCOVERY_PORT: int = 12888
STREAMING_PORT: int = 12889
HOST: str = "0.0.0.0"
AUDIO: AudioConfig | None = None
def __post_init__(self) -> None:
"""Initialize default values after instantiation."""
if self.CODECS is None:
self.CODECS = ["PCM"]
if self.AUDIO is None:
self.AUDIO = AudioConfig()
server_name: str = "AriaCast Speaker"
version: str = "1.0"
platform: str = "MusicAssistant"
codecs: list[str] | None = None
discovery_port: int = 12888
streaming_port: int = 12889
host: str = "0.0.0.0"
audio: AudioConfig | None = None
def __post_init__(self) -> None:
"""Initialize default values after instantiation."""
if self.codecs is None:
self.codecs = ["PCM"]
if self.audio is None:
self.audio = AudioConfig()
@property
def SERVER_NAME(self) -> str:
"""Backward-compatible alias for server_name."""
return self.server_name
@SERVER_NAME.setter
def SERVER_NAME(self, value: str) -> None:
self.server_name = value
@property
def VERSION(self) -> str:
"""Backward-compatible alias for version."""
return self.version
@VERSION.setter
def VERSION(self, value: str) -> None:
self.version = value
@property
def PLATFORM(self) -> str:
"""Backward-compatible alias for platform."""
return self.platform
@PLATFORM.setter
def PLATFORM(self, value: str) -> None:
self.platform = value
@property
def CODECS(self) -> list[str] | None:
"""Backward-compatible alias for codecs."""
return self.codecs
@CODECS.setter
def CODECS(self, value: list[str] | None) -> None:
self.codecs = value
@property
def DISCOVERY_PORT(self) -> int:
"""Backward-compatible alias for discovery_port."""
return self.discovery_port
@DISCOVERY_PORT.setter
def DISCOVERY_PORT(self, value: int) -> None:
self.discovery_port = value
@property
def STREAMING_PORT(self) -> int:
"""Backward-compatible alias for streaming_port."""
return self.streaming_port
@STREAMING_PORT.setter
def STREAMING_PORT(self, value: int) -> None:
self.streaming_port = value
@property
def HOST(self) -> str:
"""Backward-compatible alias for host."""
return self.host
@HOST.setter
def HOST(self, value: str) -> None:
self.host = value
@property
def AUDIO(self) -> AudioConfig | None:
"""Backward-compatible alias for audio."""
return self.audio
@AUDIO.setter
def AUDIO(self, value: AudioConfig | None) -> None:
self.audio = value

Copilot uses AI. Check for mistakes.
Comment on lines 42 to 50
for key in [
"title",
"artist",
"album",
"artwork_url",
"duration_ms",
"position_ms",
"is_playing",
]:
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metadata update logic checks each field individually (lines 42-52) but could be simplified. The explicit list of keys is duplicated from the initial metadata structure. Consider iterating over self.current_metadata.keys() to avoid maintaining two separate lists of field names, reducing the chance of them getting out of sync.

Suggested change
for key in [
"title",
"artist",
"album",
"artwork_url",
"duration_ms",
"position_ms",
"is_playing",
]:
# Update only known metadata fields, using current_metadata as the source of truth
for key in self.current_metadata.keys():

Copilot uses AI. Check for mistakes.
Comment on lines 662 to 667
async def handle_control_ws(self, request: web.Request) -> web.WebSocketResponse:
"""WebSocket handler for /control endpoint."""
ws = web.WebSocketResponse()
await ws.prepare(request)

self._control_client = ws
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The control client is not tracked in a list like metadata clients are. If multiple control clients attempt to connect, the second client will overwrite self._control_client, causing commands to only be sent to the most recent client and potentially orphaning previous connections. Consider either preventing multiple control client connections (like with audio clients) or tracking all control clients in a list and broadcasting to all of them.

Copilot uses AI. Check for mistakes.
if meta.duration != new_duration:
meta.duration = new_duration
has_changes = True
except (ValueError, TypeError):
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
meta.elapsed_time = new_position
meta.elapsed_time_last_updated = int(time.time())
has_changes = True
except (ValueError, TypeError):
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
@OzGav
Copy link
Contributor

OzGav commented Feb 5, 2026

If you can resolve the copilot comments by either actioning or explaining why it is incorrect that would be great.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 20 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 39 to 42
# Merge mapped keys into metadata (preferring existing snake_case if present)
for camel, snake in key_mapping.items():
if camel in metadata and snake not in metadata:
metadata[snake] = metadata[camel]
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metadata update logic modifies the input dictionary by adding snake_case keys when camelCase keys are present (lines 40-42). This mutates the caller's dictionary, which is unexpected behavior and could cause issues if the caller reuses the dictionary. Consider creating a new dictionary or explicitly documenting this side effect in the docstring.

Copilot uses AI. Check for mistakes.
Comment on lines 539 to 550
if not self._playback_started and len(self.frame_queue) >= prebuffer:
# Note: check for target player first
target_player_id = self._get_target_player_id()
if target_player_id:
self._playback_started = True # Prevent multiple calls
self._active_player_id = target_player_id
# Use a task to not block the receiver loop
self.mass.create_task(self._start_playback(target_player_id))
else:
self.logger.warning("No player available for AriaCast playback")
self._playback_started = False

Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential race condition in playback startup. _playback_started is set to True before _start_playback is called (line 543), but if _start_playback fails, the flag is cleared inside that function (line 570). However, if the audio WebSocket handler receives more frames before the failure is handled, they will be queued but playback won't actually start. Consider setting _playback_started only after successful playback initialization, or adding better error recovery.

Copilot uses AI. Check for mistakes.
Comment on lines 1017 to 1038
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
"""Handle discovery request."""
try:
# Note: Basic discovery without authentication or rate limiting.
# Hostile network environments might require adding rate limiting per source IP.
message = data.decode("utf-8").strip()
if message == "DISCOVER_AUDIOCAST":
local_ip = self._get_local_ip()
config = self.provider.server_config
audio = cast("AudioConfig", config.audio)
response = {
"server_name": config.server_name,
"ip": local_ip,
"port": config.streaming_port,
"samplerate": audio.sample_rate,
"channels": audio.channels,
}
if self.transport:
self.transport.sendto(json.dumps(response).encode(), addr)
self.logger.debug("Sent discovery response to %s", addr)
except Exception as e:
self.logger.debug("Error handling discovery request: %s", e)
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UDP discovery protocol has no rate limiting, making it vulnerable to denial-of-service attacks. A malicious client could flood the server with DISCOVER_AUDIOCAST requests, overwhelming the system. Consider implementing per-IP rate limiting or connection throttling to protect against abuse. The comment on line 1020-1021 acknowledges this issue but doesn't address it.

Copilot uses AI. Check for mistakes.
Comment on lines 574 to 630
async def handle_metadata_ws(self, request: web.Request) -> web.WebSocketResponse:
"""WebSocket handler for /metadata endpoint."""
ws = web.WebSocketResponse()
await ws.prepare(request)

peer = request.remote
self.logger.debug("Metadata client connected: %s", peer)

# Add to metadata clients list
self.metadata_clients.append(ws)

# Send current metadata
try:
current_metadata = self.metadata_handler.get()
await ws.send_json(
{
"type": "metadata",
"data": current_metadata,
}
)
except Exception as e:
self.logger.debug("Failed to send current metadata: %s", e)

try:
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
# Accept both legacy "update" and "metadata" types from clients
msg_type = data.get("type") if isinstance(data, dict) else None
if msg_type in ("update", "metadata"):
metadata = data.get("data", {})
else:
# Not wrapped: accept dict with direct metadata fields
metadata = data if isinstance(data, dict) else {}

if metadata:
self.logger.debug("Received metadata from %s: %s", peer, metadata)
self.metadata_handler.update(metadata)
self._update_source_metadata(metadata)
# Broadcast to all other metadata clients
await self._broadcast_metadata(metadata, exclude_ws=ws)
except json.JSONDecodeError:
self.logger.debug(
"Failed to decode JSON metadata from %s: %r", peer, msg.data
)
elif msg.type == web.WSMsgType.ERROR:
break
except Exception as e:
self.logger.debug("Error in metadata handler: %s", e)
finally:
# Remove from clients list
if ws in self.metadata_clients:
self.metadata_clients.remove(ws)
self.logger.debug("Metadata client disconnected: %s", peer)

return ws
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metadata WebSocket handler accepts metadata updates from any connected client without authentication or validation. This could allow malicious clients on the same network to inject arbitrary metadata into the playback session. Consider adding authentication or at least validating that metadata updates come from the same client that's streaming audio.

Copilot uses AI. Check for mistakes.
Comment on lines 906 to 912
img_data = await response.read()
if not img_data:
self.logger.debug(
"Skipping artwork download from %s due to empty response body",
artwork_url,
)
return
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The artwork download function downloads images from arbitrary URLs without validating the content size before reading the entire response. While there's a check for Content-Length header (5MB limit), this header is optional and can be omitted by malicious servers. A malicious server could send an unlimited stream of data, causing memory exhaustion. Consider setting a hard limit on response.read() or reading in chunks with a total size limit.

Copilot uses AI. Check for mistakes.
Comment on lines 1040 to 1049
@staticmethod
def _get_local_ip() -> str:
"""Get local IP address."""
s: socket.socket | None = None
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
return str(s.getsockname()[0])
except Exception:
return "127.0.0.1"
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _get_local_ip method connects to Google's DNS server (8.8.8.8) to determine the local IP address. This will fail in environments without internet access or where outbound UDP is blocked. This is a common pattern but consider adding a fallback that enumerates network interfaces when the connection fails, or using the socket from the UDP server itself to get the local address.

Copilot uses AI. Check for mistakes.
Comment on lines 531 to 533
# Drop silent/muted frames to avoid buffer buildup during silence
if data == bytes(len(data)):
continue
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The silence detection check using data == bytes(len(data)) is incorrect. This creates a bytes object filled with zeros and checks for exact equality, but PCM audio data is structured with samples spread across channels. A proper silence check should analyze the audio samples considering the sample width and channels. Consider checking if all samples are below a small threshold instead of checking for all zeros.

Copilot uses AI. Check for mistakes.
Comment on lines 505 to 518
# Clear active player
current_player_id = self._source_details.in_use_by
self._clear_active_player()

# Deselect source from player
if current_player_id:
try:
await self.mass.players.select_source(current_player_id, None)
except Exception as e:
self.logger.error(
"Failed to deselect AriaCast source from player %s: %s",
current_player_id,
e,
)
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the audio WebSocket handler cleanup, there's a potential issue where the active player is cleared and the source is deselected, but if the select_source call raises an exception, the player may be left in an inconsistent state. While the exception is caught, the player might still have the source selected internally but the plugin's state shows it as cleared. Consider adding more robust cleanup or state reconciliation.

Copilot uses AI. Check for mistakes.
Comment on lines 18 to 24
def __post_init__(self) -> None:
"""Provide uppercase aliases for backwards compatibility."""
self.SAMPLE_RATE = self.sample_rate
self.CHANNELS = self.channels
self.SAMPLE_WIDTH = self.sample_width
self.FRAME_DURATION_MS = self.frame_duration_ms
self.FRAME_SIZE = self.frame_size
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backwards compatibility aliases (SAMPLE_RATE, CHANNELS, etc.) created in __post_init__ are not documented. If these are for compatibility with existing code, they should be documented. If they're not needed, they should be removed to avoid confusion and reduce maintenance burden. Consider adding a comment explaining why these uppercase aliases exist.

Copilot uses AI. Check for mistakes.
Comment on lines 19 to 24
"""Provide uppercase aliases for backwards compatibility."""
self.SAMPLE_RATE = self.sample_rate
self.CHANNELS = self.channels
self.SAMPLE_WIDTH = self.sample_width
self.FRAME_DURATION_MS = self.frame_duration_ms
self.FRAME_SIZE = self.frame_size
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment in the dataclass describes "Provide uppercase aliases for backwards compatibility" but this is a new plugin being added. There is no prior version to be backwards compatible with. Either this comment is misleading or there's unnecessary code. If this is copying a pattern from elsewhere, consider whether it's actually needed for a new implementation.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 11, 2026 19:10
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 8 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- Switched from pipe to stdout
- Dropped linux arm
- Moved _get_binary_path() to helpers.py
Copilot AI review requested due to automatic review settings February 12, 2026 09:56
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 8 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@AirPlr
Copy link
Author

AirPlr commented Feb 12, 2026

Addressed last review from marcelveldt

  • Switched from pipe to stdout
  • Dropped linux arm
  • Moved _get_binary_path() to helpers.py

Copilot AI review requested due to automatic review settings February 14, 2026 11:50
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 8 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

music_assistant/providers/ariacast_receiver/init.py:506

  • _on_source_selected currently just stores in_use_by as _active_player_id, but it doesn’t enforce the CONF_ALLOW_PLAYER_SWITCH setting. Since passive only affects resume behavior (it doesn’t prevent users from selecting this plugin source on a different player), manual switching will still be possible even when the config disables it. Consider mirroring the approach used in spotify_connect’s _on_source_selected to reject/rollback disallowed selections and trigger a player update.
    async def _on_source_selected(self) -> None:
        """Handle manual selection in UI."""
        new_player_id = self._source_details.in_use_by
        if new_player_id:
            self._active_player_id = new_player_id


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

if not self._stop_called:
self.logger.debug(
"WebSocket connection to AriaCast metadata stream failed: %s", exc
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should introduce a backoff time here to avoid retrying in quick succession.

await self._binary_process.start()

# Start Metadata Monitor
await asyncio.sleep(1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this sleep? There's retry logic in monitor_metadata, so I am not sure why we are sleeping for 1s here

args = [binary_path, "--stdout"]

self.logger.info("Starting AriaCast binary: %s", binary_path)
self._binary_process = AsyncProcess(args, name="ariacast", stdout=True, stderr=False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it make sense to also set stderr to True here? Then you can use iter_stderr to listen for any errors that might be thrown by the binary?

"name": "AriaCast Receiver",
"stage": "alpha",
"description": "Receive AriaCast audio streams over WebSocket and use them as a source in Music Assistant.",
"codeowners": ["@music-assistant"],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be @AirPlr I think ;-)

"stage": "alpha",
"description": "Receive AriaCast audio streams over WebSocket and use them as a source in Music Assistant.",
"codeowners": ["@music-assistant"],
"documentation": "https://github.com/music-assistant/server/tree/main/music_assistant/providers/ariacast_receiver",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a separate repo for our documentation website here, When this PR is ready to be merged, we can update the url with one that links to musicassistant.io

from pathlib import Path


async def _get_binary_path() -> str:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is async?

binary_name = f"ariacast_{system}_{arch}"
binary_path = os.path.join(base_dir, binary_name)

if not os.path.exists(binary_path):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is blocking IO and should be wrapped in asyncio.to_thread()

self._current_track_title: str | None = None # Track song changes

# Audio buffer - larger for high-latency players like Sendspin
self.max_frames = 75 # 1.5 second buffer (75 frames * 20ms each)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We actually have a PR with some huge Sendspin improvements including lower latency for plugins like this one. We might be able to reduce this after that has been merged


return str(self._default_player_id)

async def _on_source_selected(self) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have a look at Spotify connect. This could be extended with rejecting source select requests for instances where 'allow player switch' is not allowed.

Copy link
Contributor

@MarvinSchenkel MarvinSchenkel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are getting close 👏 . Please have a look at my comments and let me know when you have looked at them by marking the PR as 'Ready for review' again.

@MarvinSchenkel MarvinSchenkel marked this pull request as draft February 17, 2026 15:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants