AirPlay 2 device groups (APR-18) - #17
Open
jcarbaugh wants to merge 7 commits into
Open
Conversation
Lets users specify multiple AirPlay receivers while keeping the single-target form working unchanged. The first entry is the group leader; remaining entries will be followers once the group streaming path lands (APR-22). Until then, downstream code uses `airplay_target[0]`. - config: `CuspConfig.airplay_target` is now `list[str] | None`, and `[airplay].target` in TOML accepts either a string or an array; both are normalized to a stripped, non-empty list. - CLI: `-t/--target` splits on commas, trims, and drops empties. - Rejects an empty/whitespace-only list with the existing "No AirPlay target specified" error. - cusp.toml.example: documents the list form + leader semantics. - Tests in tests/test_config.py cover single string, TOML array, whitespace/empty handling, CLI comma form, and CLI empty rejection. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Adds `resolve_targets(config)` which scans once and returns every reachable device in configured order (leader first). Missing or ambiguous followers are logged and skipped so a single offline speaker cannot stop the session; an ambiguous leader still fails hard. When no device resolves, the raised ConnectionError lists every name that was tried. Factors the per-device credential/password wiring into `_apply_auth` so both the leader and each follower get the same auth applied during resolution. `resolve_target` is kept as a thin wrapper that returns the leader. The pipeline now holds `targets: list[BaseConfig]` guarded by an asyncio.Lock — the background refresh swaps the list atomically so upcoming group-streaming work can read it concurrently. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…-22) (#15) Add `GroupStreamingSession` that fans a single capture out to N receivers using independent `StreamingSession` instances (one pyatv connection and StreamReader per device). `start()` connects every target in parallel via `asyncio.gather(return_exceptions=True)`, logs and drops any connect failures, and raises `ConnectionError` only when zero devices survive. `feed()` skips sub-sessions whose consumer task has errored (logged once per drop). `failed()` / `exception()` only report failure when every sub-session is gone, so `run_with_reconnect` still handles total loss. `stop()` tears down every device in parallel. `run_pipeline` now uses the group session end-to-end. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Add a README section explaining CLI/TOML syntax, leader semantics, and graceful degradation. Add a test that the group keeps streaming when the leader (not just a follower) drops mid-session. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Each receiver gets its own RAOP session — the spike (APR-19) flagged that this means playback drifts between receivers in the same room over time. Add an explicit callout in README and cusp.toml.example so users considering grouped playback know the limitation up front and have a pointer to the Home-app workaround for tightly synced rooms. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Member
Author
Code reviewFound 1 issue:
cusp-audio/src/cusp/pipeline.py Lines 206 to 211 in 4c6d2ea 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CLI form accepts "A, B" and splits on commas, but the TOML loader wrapped a string value in a single-element list, so a config like `target = "HomePod Left,Family Room"` was looked up as one device and surfaced in the "No AirPlay devices found" error verbatim. Make _normalize_targets split string values on commas so the TOML string form matches the CLI form. The TOML array form is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the parent feature APR-18 — stream a single capture to multiple AirPlay receivers, with a fan-out architecture chosen after the APR-19 spike found that pyatv 0.17.0 has no native AirPlay 2 group API.
Aggregates the four sub-issue PRs already merged into this branch:
resolve_targetsreturns surviving devices in configured order; missing followers are logged and skipped, missing leader still hard-fails.GroupStreamingSessionruns onepyatv.connect+stream_fileper receiver from a single capture; per-device fault isolation, group only fails when every sub-session has died.Plus one follow-up commit on this PR documenting the fan-out sync-drift tradeoff in README and
cusp.toml.example, which the spike flagged but wasn't called out in user-facing docs.Single-target usage (
-t "Living Room"/target = "Living Room") is unchanged.Test plan
uv run pytest— 91 tests pass locallyuv run ruff check .anduv run ruff format --check .clean