Skip to content

AirPlay 2 device groups (APR-18) - #17

Open
jcarbaugh wants to merge 7 commits into
mainfrom
jeremy/apr-18-airplay-2-device-groups
Open

AirPlay 2 device groups (APR-18)#17
jcarbaugh wants to merge 7 commits into
mainfrom
jeremy/apr-18-airplay-2-device-groups

Conversation

@jcarbaugh

@jcarbaugh jcarbaugh commented Apr 18, 2026

Copy link
Copy Markdown
Member

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:

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 locally
  • uv run ruff check . and uv run ruff format --check . clean
  • CI green on this PR
  • Smoke test: stream to a single receiver (regression check) and to a 2+ receiver group; pull power on a follower mid-stream and confirm the leader keeps playing

jcarbaugh and others added 5 commits April 18, 2026 11:20
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>
@jcarbaugh

Copy link
Copy Markdown
Member Author

Code review

Found 1 issue:

  1. Stale comment claims targets_lock is for "a later sub-issue" that will use group streaming, but group streaming is already implemented and actively uses the lock in this same PR. The comment will mislead future readers into thinking the lock is forward-looking scaffolding when it is load-bearing today.

targets: list[BaseConfig] = await resolve_targets(config)
# Guards swaps of `targets` so a concurrent reader (session start) never
# sees a torn list during a background refresh. Group streaming in a
# later sub-issue will rely on this.
targets_lock = asyncio.Lock()

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

jcarbaugh and others added 2 commits April 18, 2026 12:28
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant