From f8abb3acbf474b80c0c003a253d2299a137981e2 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 17 Feb 2026 13:40:24 +0100 Subject: [PATCH] Some small tweaks to the AirPlay provider --- music_assistant/providers/airplay/README.md | 485 ++++++++++++++++++ .../providers/airplay/constants.py | 22 +- music_assistant/providers/airplay/helpers.py | 2 +- music_assistant/providers/airplay/player.py | 37 +- music_assistant/providers/airplay/provider.py | 9 +- .../providers/airplay/stream_session.py | 32 +- 6 files changed, 536 insertions(+), 51 deletions(-) create mode 100644 music_assistant/providers/airplay/README.md diff --git a/music_assistant/providers/airplay/README.md b/music_assistant/providers/airplay/README.md new file mode 100644 index 00000000..9405f3d1 --- /dev/null +++ b/music_assistant/providers/airplay/README.md @@ -0,0 +1,485 @@ +# AirPlay Provider + +## Overview + +The AirPlay provider enables Music Assistant to stream audio to AirPlay-enabled devices on your local network. It supports both **RAOP (AirPlay 1)** and **AirPlay 2** protocols, providing compatibility with a wide range of devices including Apple HomePods, Apple TVs, Macs, and third-party AirPlay-compatible speakers. + +### Key Features + +- **Dual Protocol Support**: Automatically selects between RAOP and AirPlay 2 based on device capabilities +- **Native Pairing**: Supports pairing with Apple devices (Apple TV, HomePod, Mac) using HAP (HomeKit Accessory Protocol) or RAOP pairing +- **Multi-Room Audio**: Synchronizes playback across multiple AirPlay devices with NTP timestamp precision +- **DACP Remote Control**: Receives remote control commands (play/pause/volume/next/previous) from devices while streaming +- **Late Join Support**: Allows adding players to an existing playback session without interrupting other players +- **Flow Mode Streaming**: Provides gapless playback and crossfade support by streaming the queue as one continuous audio stream + +## Architecture + +### Component Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AirPlay Provider │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ MDNS Discovery (_airplay._tcp, _raop._tcp) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ DACP Server (_dacp._tcp) - Remote Control Callbacks │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ +┌───────▼──────┐ ┌────────▼────────┐ ┌──────▼──────┐ +│ AirPlayPlayer│ │ AirPlayPlayer │ │AirPlayPlayer│ +│ (Leader) │ │ (Sync Child) │ │(Sync Child) │ +└───────┬──────┘ └────────┬────────┘ └──────┬──────┘ + │ │ │ + └─────────────────────┼─────────────────────┘ + │ + ┌─────────▼──────────┐ + │ AirPlayStreamSession│ + │ (manages session) │ + └─────────┬──────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ +┌───────▼──────┐ ┌────────▼────────┐ ┌──────▼──────┐ +│ RaopStream │ │ AirPlay2Stream │ │ RaopStream │ +│ ┌──────────┐ │ │ ┌────────────┐ │ │┌──────────┐ │ +│ │ cliraop │ │ │ │ cliap2 │ │ ││ cliraop │ │ +│ └────▲─────┘ │ │ └─────▲──────┘ │ │└────▲─────┘ │ +│ │ │ │ │ │ │ │ │ +│ ┌────┴─────┐ │ │ ┌─────┴──────┐ │ │┌────┴─────┐ │ +│ │ FFmpeg │ │ │ │ FFmpeg │ │ ││ FFmpeg │ │ +│ └──────────┘ │ │ └────────────┘ │ │└──────────┘ │ +└──────────────┘ └─────────────────┘ └─────────────┘ +``` + +### File Structure + +``` +airplay/ +├── provider.py # Main provider class, MDNS discovery, DACP server +├── player.py # AirPlayPlayer implementation +├── stream_session.py # Manages streaming sessions for synchronized playback +├── pairing.py # HAP and RAOP pairing implementations +├── helpers.py # Utility functions (NTP conversion, model detection, etc.) +├── constants.py # Constants and enums +├── protocols/ +│ ├── _protocol.py # Base protocol class with shared logic +│ ├── raop.py # RAOP (AirPlay 1) streaming implementation +│ └── airplay2.py # AirPlay 2 streaming implementation +└── bin/ # Platform-specific CLI binaries + ├── cliraop-* # RAOP streaming binaries + └── cliap2-* # AirPlay 2 streaming binaries +``` + +## Protocol Selection: RAOP vs AirPlay 2 + +### RAOP (AirPlay 1) + +- **Used for**: Older AirPlay devices, some third-party implementations +- **Features**: + - Encryption support (can be disabled for problematic devices) + - ALAC compression option to save network bandwidth + - Password protection support + - Device-reported volume feedback via DACP +- **Binary**: `cliraop` (based on [libraop](https://github.com/music-assistant/libraop)) + +### AirPlay 2 + +- **Used for**: Modern Apple devices, some third-party devices +- **Features**: + - Better compatibility with newer devices + - More robust protocol + - Required for some devices that don't support RAOP +- **Binary**: `cliap2` (based on [OwnTone](https://github.com/music-assistant/cliairplay)) + +### Automatic Selection + +When protocol is set to "Automatically select" (default): +- **Prefers AirPlay 2** for known models (e.g., Ubiquiti devices) that work better with it +- **Falls back to RAOP** for all other devices +- Users can manually override via player configuration if needed + +## Discovery and Player Setup + +### MDNS Service Discovery + +The provider discovers AirPlay devices via two MDNS service types: + +1. **`_airplay._tcp.local.`** - Primary AirPlay service (preferred) + - Contains detailed device information + - Announced by most modern devices + +2. **`_raop._tcp.local.`** - Legacy RAOP service + - Fallback for older devices + - If only RAOP service is found, provider attempts to query for AirPlay service + +### Player Setup Flow + +1. **MDNS service discovered** → `on_mdns_service_state_change()` in [provider.py](provider.py) +2. **Extract device info** from MDNS properties: + - Device ID (from `deviceid` property or service name) + - Display name + - Manufacturer and model (via `get_model_info()` in [helpers.py](helpers.py)) +3. **Filter checks**: + - Skip if player is disabled in config + - Skip ShairportSync instances running on the same Music Assistant server (to avoid conflicts with AirPlay Receiver provider) +4. **Create player** → `AirPlayPlayer` instance +5. **Register with player controller** → `mass.players.register()` + +### Player ID Format + +Player IDs follow the format: `ap{mac_address}` (e.g., `ap1a2b3c4d5e6f`) + +## Pairing for Apple Devices + +Apple TV and Mac devices require pairing before they can be used for streaming. + +### Pairing Protocols + +1. **HAP (HomeKit Accessory Protocol)** - For AirPlay 2 + - 6-step SRP authentication with TLV encoding + - Ed25519 key exchange + - ChaCha20-Poly1305 encryption + - Produces 192-character hex credentials + +2. **RAOP Pairing** - For AirPlay 1 + - 3-step SRP authentication with plist encoding + - Ed25519 key derivation from auth secret + - AES-GCM encryption + - Produces `client_id:auth_secret` format credentials + +### Pairing Flow + +1. **Start pairing** → POST to `/pair-pin-start` (or protocol-specific endpoint) +2. **Device displays 4-digit PIN** on screen +3. **User enters PIN** in Music Assistant configuration +4. **Complete pairing** → SRP authentication and key exchange +5. **Store credentials** in player config (protocol-specific key: `raop_credentials` or `airplay_credentials`) + +**Important**: The DACP ID used during pairing must match the ID used during streaming. The provider uses the first 16 hex characters of `server_id` as a persistent DACP ID to ensure compatibility across restarts. + +## Streaming Architecture + +### Audio Pipeline + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Music Assistant Core │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Queue Manager (assembles tracks into continuous stream) │ │ +│ └─────────────────────────┬────────────────────────────────┘ │ +└────────────────────────────┼─────────────────────────────────────┘ + │ PCM Audio (44.1kHz, 32-bit float) + ┌────────▼─────────┐ + │ StreamSession │ + │ _audio_streamer()│ + └────────┬─────────┘ + │ Chunks of PCM audio + ┌────────────────────┼────────────────────┐ + │ │ │ +┌───────▼──────┐ ┌────────▼────────┐ ┌──────▼──────┐ +│ FFmpeg │ │ FFmpeg │ │ FFmpeg │ +│ (resample, │ │ (resample, │ │ (resample, │ +│ filter, │ │ filter, │ │ filter, │ +│ convert) │ │ convert) │ │ convert) │ +└───────┬──────┘ └────────┬────────┘ └──────┬──────┘ + │ PCM 44.1kHz 16-bit │ │ +┌───────▼──────┐ ┌────────▼────────┐ ┌──────▼──────┐ +│ cliraop │ │ cliap2 │ │ cliraop │ +│ (RAOP │ │ (AirPlay 2 │ │ (RAOP │ +│ protocol) │ │ protocol) │ │ protocol) │ +└───────┬──────┘ └────────┬────────┘ └──────┬──────┘ + │ │ │ + │ Network (RTP) │ Network (RTP) │ Network (RTP) + │ │ │ +┌───────▼──────┐ ┌────────▼────────┐ ┌──────▼──────┐ +│ AirPlay │ │ AirPlay │ │ AirPlay │ +│ Device 1 │ │ Device 2 │ │ Device 3 │ +└──────────────┘ └─────────────────┘ └─────────────┘ +``` + +### Stream Session Management + +The `AirPlayStreamSession` class in [stream_session.py](stream_session.py) manages streaming to one or more synchronized players: + +1. **Initialization** (`start()` method) + - Calculates start time with connection delay buffer + - Converts start time to NTP timestamp for precise synchronization + +2. **Client Setup** (per player, `_start_client()` method) + - Creates protocol instance (`RaopStream` or `AirPlay2Stream`) + - Starts CLI process with NTP start timestamp + - Configures FFmpeg for audio format conversion and optional DSP filters + - Pipes FFmpeg output to CLI process stdin + +3. **Audio Streaming** (`_audio_streamer()` method) + - Receives PCM audio chunks from Music Assistant core + - Distributes chunks to all players via FFmpeg + - Tracks elapsed time based on bytes sent + - Handles silence padding if audio source is slow (watchdog mechanism) + +4. **Connection Monitoring** + - Waits for all devices to connect before starting playback + - Monitors CLI stderr for connection status and errors + - Removes players that fail to keep up (write timeouts) + +### Flow Mode Streaming + +AirPlay uses **flow mode** streaming, which means: +- The entire queue is streamed as one continuous audio stream +- Enables true gapless playback between tracks +- Supports crossfade between tracks +- Once started, the stream continues until explicitly stopped + + +## Multi-Room Synchronization + +### Synchronized Playback + +The provider supports synchronized multi-room audio by: + +1. **Using a single `AirPlayStreamSession`** for the group leader and all sync children +2. **Coordinating start times** via NTP timestamps +3. **Distributing identical audio** to all players simultaneously +4. **Per-player sync adjustment** via `sync_adjust` config option (in milliseconds) + +### Group Management + +- **Leader**: The primary player that manages the stream session +- **Members**: Child players synchronized to the leader +- **Adding members**: Use `set_members()` method in [player.py](player.py) +- **Removing members**: Stream continues for remaining players + +### Late Join Support + +When adding a player to an already-playing session (`add_client()` in [stream_session.py](stream_session.py)): + +1. **Calculate offset**: Determine how much audio has already been sent +2. **Adjusted start time**: Start new player at `original_start_time + offset` +3. **Receive same stream**: New player receives the same audio chunks as existing players +4. **Automatic synchronization**: NTP timestamps keep all players in sync + +**Config option**: `enable_late_join` (default: `True`) +- If disabled: Session restarts with all players when members change +- If enabled: New players join seamlessly without interrupting others + +## DACP (Digital Audio Control Protocol) + +### Purpose + +DACP allows AirPlay devices to send remote control commands back to Music Assistant while streaming is active. This enables: +- Using physical buttons on devices (e.g., Apple TV remote) +- Volume control from the device +- Play/pause/next/previous commands +- Shuffle toggle +- Source switching detection + +### DACP Server + +The provider registers a MDNS service `_dacp._tcp.local.` (in `handle_async_init()` method in [provider.py](provider.py)) and runs a TCP server to receive HTTP requests from devices. + +### Active-Remote ID + +Each streaming session generates an `active_remote_id` (via `generate_active_remote_id()` in [helpers.py](helpers.py)) from the player's MAC address. This ID is: +- Passed to the CLI binary +- Sent to the device during streaming +- Used to match incoming DACP requests to the correct player + +### Supported DACP Commands + +Handled in `_handle_dacp_request()` in [provider.py](provider.py): + +| DACP Path | Action | +|-----------|--------| +| `/ctrl-int/1/nextitem` | Skip to next track | +| `/ctrl-int/1/previtem` | Go to previous track | +| `/ctrl-int/1/play` | Resume playback | +| `/ctrl-int/1/pause` | Pause playback | +| `/ctrl-int/1/playpause` | Toggle play/pause | +| `/ctrl-int/1/stop` | Stop playback | +| `/ctrl-int/1/volumeup` | Increase volume | +| `/ctrl-int/1/volumedown` | Decrease volume | +| `/ctrl-int/1/shuffle_songs` | Toggle shuffle | +| `dmcp.device-volume=X` | Volume changed by device (RAOP only) | +| `device-prevent-playback=1` | Device switched to another source or powered off | +| `device-prevent-playback=0` | Device ready for playback again | + +### Volume Feedback + +Both **RAOP** and **AirPlay 2** protocols support devices reporting their volume level via DACP. + +**Config option**: `ignore_volume` (default: `False`, auto-enabled for Apple devices) +- Useful when device volume reports are unreliable +- Apple devices always ignore volume feedback (handled internally) + +### Device Source Switching + +When `device-prevent-playback=1` is received: +- User switched the device to another input source +- Device is powered off +- Streaming session removes the player from the active session + +## External CLI Binaries + +### Why External Binaries? + +Python is not suitable for real-time audio streaming with precise timing requirements. The AirPlay protocols (especially AirPlay 2) require: +- Accurate NTP timestamp handling +- Real-time RTP packet transmission +- Low-latency audio buffering +- Precise synchronization across multiple devices + +Therefore, the provider uses C-based CLI binaries for the actual streaming. + +### Binary Selection + +The provider automatically selects the correct binary based on: +- **Platform**: Linux, macOS +- **Architecture**: x86_64, arm64, aarch64 +- **Protocol**: RAOP (`cliraop-*`) or AirPlay 2 (`cliap2-*`) + +Binaries are located in [bin/](bin/) directory and validated on first use. + +### Binary Communication + +**Input** (stdin): +- PCM audio data piped from FFmpeg + +**Commands** (named pipe): +- Interactive commands sent via `AsyncNamedPipeWriter` +- Examples: `ACTION=PLAY`, `ACTION=PAUSE`, `VOLUME=50`, `TITLE=Song Name` + +**Output** (stderr): +- Status messages and logs +- Connection state +- Playback state changes +- Elapsed time updates +- Error messages + +The provider monitors stderr in a separate task (`_stderr_reader()` in [raop.py](protocols/raop.py) and [airplay2.py](protocols/airplay2.py)) to: +- Update player state +- Detect connection completion +- Handle errors and packet loss +- Track elapsed time + +## NTP Timestamp Synchronization + +AirPlay uses **NTP (Network Time Protocol)** timestamps for synchronized playback. + +### NTP Format + +- **64-bit integer**: Upper 32 bits = seconds, lower 32 bits = fractional seconds +- **NTP epoch**: January 1, 1900 (not Unix epoch 1970) +- **Precision**: Nanosecond-level timing + +### Key Functions + +Available in [helpers.py](helpers.py): +- `get_ntp_timestamp()`: Get current NTP time +- `ntp_to_unix_time()`: Convert NTP to Unix timestamp +- `unix_time_to_ntp()`: Convert Unix to NTP timestamp +- `add_seconds_to_ntp()`: Add offset to NTP timestamp + +### Usage in Streaming + +1. Calculate desired start time: `current_time + connection_buffer` +2. Convert to NTP timestamp +3. Pass to CLI binary via `-ntpstart` argument +4. All players start at the exact same NTP time +5. Per-player `sync_adjust` config allows fine-tuning (+/- milliseconds) + +## Player Types + +The provider creates players with different types based on whether the device is a native Apple player or a third-party AirPlay receiver. + +### PlayerType.PLAYER +- **Devices**: Apple HomePod, Apple TV, Mac +- **Reason**: These are standalone music players with native AirPlay support +- **Behavior**: Exposed as top-level players in Music Assistant UI +- **Not merged**: These players are NOT combined with other protocols + +### PlayerType.PROTOCOL +- **Devices**: Third-party AirPlay receivers (Sonos, receivers, smart speakers, soundbars) +- **Reason**: AirPlay is just one output protocol among many for these devices (often supporting Chromecast, DLNA, etc.) +- **Behavior**: Automatically merged into a **Universal Player** if other protocols are detected for the same device +- **Example**: A Sonos speaker supporting both AirPlay and Chromecast will appear as a single "Sonos" player with selectable output protocols + +**Detection**: Player type is determined in [player.py](player.py) `__init__()` method based on `manufacturer == "Apple"` + +**For more details on output protocols and protocol linking**, see the [Player Controller README](../../controllers/players/README.md), which explains: +- How multiple protocol players for the same physical device are automatically linked +- The Universal Player concept for devices without native vendor support +- Protocol selection and device identifier matching +- Native player linking vs. Universal Player creation + +## Configuration Options + +### Protocol Selection +- **`airplay_protocol`**: Choose RAOP, AirPlay 2, or automatic (default: automatic) + +### RAOP-Specific +- **`encryption`**: Enable/disable encryption (default: enabled) +- **`alac_encode`**: Enable ALAC compression to save bandwidth (default: enabled) +- **`ignore_volume`**: Ignore device volume reports (default: false) + +### General +- **`password`**: Device password if required +- **`sync_adjust`**: Per-player timing adjustment in milliseconds (default: 0) + +### Pairing (Apple devices only) +- **`raop_credentials`**: Stored RAOP pairing credentials (hidden) +- **`airplay_credentials`**: Stored AirPlay 2 pairing credentials (hidden) + +## Known Issues + +### Broken AirPlay Models + +Some devices have known broken AirPlay implementations (see `BROKEN_AIRPLAY_MODELS` in [constants.py](constants.py)): +- **Samsung devices**: Known issues with both RAOP and AirPlay 2 +- These players are disabled by default + +### Limitations + +1. **DACP remote control**: Only active while streaming (not when idle) +2. **Pause while synced**: Not supported; uses stop instead +3. **Companion protocol**: Not yet implemented for idle state monitoring + +## Development Notes + +### Testing CLI Binaries + +Each binary can be validated with a test command: +- **cliraop**: `cliraop -check` (should output "cliraop check") +- **cliap2**: `cliap2 --testrun` (should output "cliap2 check") + +### Adding New CLI Commands + +To add a new command to the CLI binaries: +1. Update the CLI binary source code (external repositories) +2. Update `send_cli_command()` method in [_protocol.py](protocols/_protocol.py) +3. Send command via named pipe: `await stream.send_cli_command("YOUR_COMMAND=value")` + +### Debugging Streaming Issues + +Enable verbose logging in Music Assistant to see: +- CLI binary arguments +- stderr output from binaries +- DACP requests +- Connection state changes +- Packet loss warnings + +## Credits + +- **libraop**: RAOP streaming implementation - https://github.com/music-assistant/libraop +- **OwnTone**: AirPlay 2 implementation - https://github.com/OwnTone +- **pyatv**: Reference for HAP pairing protocol - https://github.com/postlund/pyatv + +## Future Enhancements + +- **Companion protocol**: Implement idle state monitoring for Apple devices +- **AirPlay 2 volume feedback**: Add DACP volume support for AirPlay 2 +- **Better late-join handling**: Reduce time to start a late joiner diff --git a/music_assistant/providers/airplay/constants.py b/music_assistant/providers/airplay/constants.py index e4c77f6c..4d38b064 100644 --- a/music_assistant/providers/airplay/constants.py +++ b/music_assistant/providers/airplay/constants.py @@ -5,7 +5,8 @@ from __future__ import annotations from enum import IntEnum from typing import Final -from music_assistant_models.enums import ContentType +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ConfigEntryType, ContentType, PlayerFeature from music_assistant_models.media_items import AudioFormat from music_assistant.constants import INTERNAL_PCM_FORMAT @@ -80,3 +81,22 @@ AIRPLAY_2_DEFAULT_MODELS = ( # These use the translated/friendly model names from get_model_info() ("Ubiquiti Inc.", "*"), ) + +BROKEN_AIRPLAY_WARN = ConfigEntry( + key="BROKEN_AIRPLAY", + type=ConfigEntryType.ALERT, + default_value=None, + required=False, + 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.", +) + +BASE_PLAYER_FEATURES: Final[set[PlayerFeature]] = { + PlayerFeature.PLAY_MEDIA, + PlayerFeature.SET_MEMBERS, + PlayerFeature.MULTI_DEVICE_DSP, + PlayerFeature.VOLUME_SET, +} diff --git a/music_assistant/providers/airplay/helpers.py b/music_assistant/providers/airplay/helpers.py index 8b1360de..c5fef7eb 100644 --- a/music_assistant/providers/airplay/helpers.py +++ b/music_assistant/providers/airplay/helpers.py @@ -137,7 +137,7 @@ def is_apple_device(manufacturer: str) -> bool: and should be exposed as PlayerType.PLAYER. Non-Apple devices with AirPlay support should be exposed as PlayerType.PROTOCOL. """ - return manufacturer.lower() == "apple" + return manufacturer.lower().startswith("apple") async def get_cli_binary(protocol: StreamingProtocol) -> str: diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index 8b039731..b9d4445d 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -21,6 +21,8 @@ from music_assistant.models.player import DeviceInfo, Player, PlayerMedia from .constants import ( AIRPLAY_DISCOVERY_TYPE, AIRPLAY_FLOW_PCM_FORMAT, + BASE_PLAYER_FEATURES, + BROKEN_AIRPLAY_WARN, CACHE_CATEGORY_PREV_VOLUME, CONF_ACTION_FINISH_PAIRING, CONF_ACTION_RESET_PAIRING, @@ -56,19 +58,6 @@ if TYPE_CHECKING: from .provider import AirPlayProvider -BROKEN_AIRPLAY_WARN = ConfigEntry( - key="BROKEN_AIRPLAY", - type=ConfigEntryType.ALERT, - default_value=None, - required=False, - 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.", -) - - class AirPlayPlayer(Player): """AirPlay Player implementation.""" @@ -104,13 +93,6 @@ class AirPlayPlayer(Player): ) self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address) self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, address) - self._attr_supported_features = { - PlayerFeature.PLAY_MEDIA, - PlayerFeature.PAUSE, - PlayerFeature.SET_MEMBERS, - PlayerFeature.MULTI_DEVICE_DSP, - PlayerFeature.VOLUME_SET, - } self._attr_volume_level = initial_volume self._attr_can_group_with = {provider.instance_id} self._attr_enabled_by_default = not is_broken_airplay_model(manufacturer, model) @@ -144,6 +126,18 @@ class AirPlayPlayer(Player): """Return if the player requires flow mode.""" return True + @property + def supported_features(self) -> set[PlayerFeature]: + """Return the supported features of this player.""" + features = set(BASE_PLAYER_FEATURES) + if not (self.group_members or self.synced_to): + # we only support pause when the player is not synced, + # because we don't want to deal with the complexity of pausing a group of players + # so in this case stop will be used to pause the stream instead of pausing it, + # which is a common approach for AirPlay players + features.add(PlayerFeature.PAUSE) + return features + async def get_config_entries( self, action: str | None = None, @@ -502,8 +496,7 @@ class AirPlayPlayer(Player): if self.stream and self.stream.running and self.stream.session: # Set transitioning flag to ignore stale DACP messages (like prevent-playback) self._transitioning = True - # Force stop the session (to speed up stopping) - await self.stream.session.stop(force=True) + await self.stream.session.stop() self.stream = None # select audio source diff --git a/music_assistant/providers/airplay/provider.py b/music_assistant/providers/airplay/provider.py index 2da0767a..8e9d5e7c 100644 --- a/music_assistant/providers/airplay/provider.py +++ b/music_assistant/providers/airplay/provider.py @@ -30,12 +30,9 @@ from .helpers import convert_airplay_volume, get_model_info from .player import AirPlayPlayer # TODO: AirPlay provider -# - Implement authentication for Apple TV -# - Implement volume control for Apple devices using pyatv -# - Implement metadata for Apple Apple devices using pyatv -# - Use pyatv for communicating with original Apple devices (and use cliraop for actual streaming) -# - Implement AirPlay 2 support -# - Implement late joining to existing stream (instead of restarting it) +# Implement Companion protocol for communicating with original Apple (TV) devices +# This allows for getting state/metadata changes from the device, +# even if we are not actively streaming to it. class AirPlayProvider(PlayerProvider): diff --git a/music_assistant/providers/airplay/stream_session.py b/music_assistant/providers/airplay/stream_session.py index 0d647dd5..830d2448 100644 --- a/music_assistant/providers/airplay/stream_session.py +++ b/music_assistant/providers/airplay/stream_session.py @@ -83,21 +83,15 @@ class AirPlayStreamSession: await self.stop() raise PlayerCommandFailed("Playback failed to start") - async def stop(self, force: bool = False) -> None: + async def stop(self) -> None: """Stop playback and cleanup.""" if self._audio_source_task and not self._audio_source_task.done(): self._audio_source_task.cancel() with suppress(asyncio.CancelledError): await self._audio_source_task - if force: - await asyncio.gather( - *[self.stop_client(x, force=True) for x in self.sync_clients], - ) - self.sync_clients = [] - else: - await asyncio.gather( - *[self.remove_client(x) for x in self.sync_clients], - ) + await asyncio.gather( + *[self.remove_client(x) for x in self.sync_clients], + ) async def remove_client(self, airplay_player: AirPlayPlayer) -> None: """Remove a sync client from the session.""" @@ -111,7 +105,7 @@ class AirPlayStreamSession: await self.stop() return - async def stop_client(self, airplay_player: AirPlayPlayer, force: bool = False) -> None: + async def stop_client(self, airplay_player: AirPlayPlayer) -> None: """ Stop a client's stream and ffmpeg. @@ -119,16 +113,12 @@ class AirPlayStreamSession: :param force: If True, kill CLI process immediately. """ ffmpeg = self._player_ffmpeg.pop(airplay_player.player_id, None) - if force: - if ffmpeg and not ffmpeg.closed: - await ffmpeg.kill() - if airplay_player.stream and airplay_player.stream.session == self: - await airplay_player.stream.stop(force=True) - else: - if ffmpeg and not ffmpeg.closed: - await ffmpeg.close() - if airplay_player.stream and airplay_player.stream.session == self: - await airplay_player.stream.stop() + # note that we use kill instead of graceful close here, + # because otherwise it can take a very long time for the process to exit. + if ffmpeg and not ffmpeg.closed: + await ffmpeg.kill() + if airplay_player.stream and airplay_player.stream.session == self: + await airplay_player.stream.stop(force=True) async def add_client(self, airplay_player: AirPlayPlayer) -> None: """Add a sync client to the session as a late joiner. -- 2.34.1