Skip to content

Add Sonos integration spec and document Hum API surface#2

Draft
betmoar wants to merge 18 commits into
mainfrom
smapi
Draft

Add Sonos integration spec and document Hum API surface#2
betmoar wants to merge 18 commits into
mainfrom
smapi

Conversation

@betmoar

@betmoar betmoar commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Summary

This PR adds comprehensive documentation for integrating Hum with Sonos via a Subsonic-compatible adapter (the "hum-subsonic-shim"), along with an updated API reference documenting Hum's full endpoint surface including live-stream and HLS support.

Changes

  • New file: docs/SONOS_SPEC.md — A complete 405-line integration specification covering:

    • Architecture decision record (why a Subsonic shim + bonob bridge vs. native SMAPI)
    • Component inventory and deployment topology
    • Subsonic API surface to implement (must-have, browsing, optional endpoints)
    • Audio delivery strategy: AAC remux (Mode 1) with mp3 re-encode fallback (Mode 2)
    • URL signing, TTL management, and shim-side caching strategy
    • S1 vs S2 deployment considerations and hardening checklist
    • Seven-phase development trajectory with exit tests for each
    • Risk register and open decisions
    • Reference endpoints (in-scope and out-of-scope)
  • Updated: README.md — Enhanced API documentation:

    • Added optional category=music and live=false filters to /api/search
    • Documented new endpoints: /api/radio, /api/hls/{id}.m3u8, /api/live/{id}/manifest.m3u8, /proxy/live-segment/{id}
    • Clarified that /api/video/{id} returns signed hls_url for AAC formats and live_stream_url for live videos
    • Added note about bearer-protected /api/debug/live/{id}/upstream for live-stream debugging
    • Updated proxy URL explanation to reflect AAC and live-stream handling

Notable Details

  • The spec is audited against Hum codebase at c68470e (Hum 0.1.0), resolving discrepancies in the original draft
  • Key audit findings: Hum exposes AAC (not just Opus), has live/radio surfaces (out of scope for Phase 1), and has zero persistence
  • The remux-first strategy (AAC via ffmpeg -c copy) significantly reduces technical risk vs. the original mp3 re-encode plan
  • Detailed latency budgeting accounts for Hum's 1-hour internal cache and 6-hour signed URL TTL
  • Development trajectory is structured for incremental validation: Amperfy testing before Sonos involvement

README: document radio, HLS-wrapper, and live endpoints; correct the
stream-URL cache TTL (1h cap, not 5min); refresh the directory tree.

docs/SONOS_SPEC.md: revised Hum->Sonos (Subsonic shim + bonob) spec,
audited against the codebase: AAC remux-first audio delivery, live-hit
filtering in search3, shim-side details caching, resolved open
decisions, and an updated in/out-of-scope endpoint inventory.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
@betmoar

betmoar commented Jun 10, 2026

Copy link
Copy Markdown
Owner Author

@copilot review spec

Copilot AI commented Jun 10, 2026

Copy link
Copy Markdown

@copilot review spec

Here's a cross-reference review of the spec against the codebase (c68470e). Overall it's accurate and well-structured. Four items worth addressing before Phase 1:


🔴 §9 (open decision #6) — Subsonic auth is underdetermined

The decision says "decide whether the shim validates or accepts" the credentials bonob sends. bonob's default is salted-MD5 token auth — it posts t (MD5 hash of password + s) + s (random salt), not plaintext p. The shim must implement the Subsonic token-auth hash check (MD5(password + salt)), not just "accept credentials." Treating this as optional will result in bonob's auth calls silently succeeding or failing depending on the shim's fallback behaviour. Recommend upgrading decision #6 to: "implement Subsonic token-auth (MD5 hash); plain p auth as fallback for Amperfy dev mode only."


🟡 §4 — Mode 1 container ordering should be inverted

ADTS (-f adts) is listed as the default, with fragmented MP4 (-f mp4 -movflags frag_keyframe+empty_moov) as a "second option before Mode 2." In practice ADTS is the riskier choice: it has no duration atom, no bitrate header, and Sonos speakers typically display no track-length or seek bar for ADTS streams. Fragmented MP4 (fMP4) preserves timing metadata and is what Hum's own HLS path produces (see app/api/hls.py — it wraps fMP4 byteranges). Recommend reversing the order: fMP4 first, ADTS as secondary experiment, mp3 as fallback.


🟡 §3.4 — _looks_live polarity not stated

The spec says "combine it with the duration heuristic already proven in app/api/radio.py:_looks_live" but radio.py uses _looks_live to keep hits (items = [h for h in raw if _looks_live(h)]). For search3, the shim must use the inverse[h for h in results if not _looks_live(h)]. Worth making that explicit in the spec (or showing the actual filter expression) to prevent an off-by-one logic error.


🟡 §5 — Cache eviction formula is ambiguous

"Recommend ~30 min, always < remaining exp" doesn't define the eviction rule when exp is close. The correct logic is cache_ttl = min(1800, exp - now - safety_buffer) (e.g. 60 s safety margin). Without this, a cached entry whose signed URL expires in 5 minutes will still be served to ffmpeg, producing a 410 from Hum mid-stream. Recommend stating the eviction formula explicitly.


✅ Everything else checks out

  • Video/playlist/channel ID constraints match the validators in models.py, playlist.py, channel.py.
  • Proxy endpoints (/proxy/audio/) use HMAC signature auth, not ****** the ffmpeg -i <signed_proxy_url> invocation works without extra headers. The ****** is only needed for /api/* calls, which the shim makes server-side.
  • The 1 h upstream URL cache and 6 h signed-URL TTL match app/adapters/youtube.py (_CACHE_MAX_TTL = 3600.0) and app/config.py (stream_url_ttl_seconds: int = 21600).
  • _looks_live heuristic, body_iterator process-lifecycle pattern, and cover-art two-source strategy are all accurately described.

…_live inversion, cache eviction formula, Subsonic auth specifics
claude and others added 16 commits June 10, 2026 09:01
- Risk register: with fMP4 now the primary remux container, the second
  variant to try is ADTS, not fMP4.
- Move the auth decision to the resolved list: the review settled it
  (token auth is mandatory for bonob), so it is no longer open.
- Restore the Phase 1 task for implementing Subsonic token auth.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
Sibling FastAPI service (shim/) implementing the minimum-playable surface
from docs/SONOS_SPEC.md SS3.1/SS7:

- /rest ping, getLicense, search3, getCoverArt, stream (+ .view aliases),
  all returning subsonic-response JSON envelopes; protocol errors travel
  inside HTTP 200 (codes 0/10/40/70)
- Subsonic salted-MD5 token auth on every endpoint; plaintext p fallback
  gated behind SHIM_ALLOW_PLAIN_PASSWORD for Amperfy dev mode (SS9.5)
- vid:/pl:/art: ID scheme mirroring Hum's validators (SS3.4)
- stream: AAC remux to fragmented MP4 via ffmpeg -c copy, mp3 256k
  re-encode fallback when no AAC format exists; range-ignoring mode (a);
  process reaped on client disconnect (SS4)
- HumClient holds the bearer token server-side, caches /api/video
  details with ttl = min(1800, exp - now - 60), drops live hits from
  search3, and sources cover art without triggering extraction (SS3.5/SS5)

Wiring: hum-shim script entry, shim* package, CI ruff/mypy over shim/,
SHIM_* settings in .env.example. 38 unit tests under tests/shim/.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
… error

Running uvicorn shim.main:app against a stock Hum .env crashed with a
raw pydantic ValidationError (which also echoes a prefix of .env values
in input_value).

- hum_bearer_token now falls back to API_BEARER_TOKEN, since the shim
  and Hum share an .env; SHIM_HUM_BEARER_TOKEN still overrides for
  split deployments.
- get_settings() exits with a one-line message naming the missing
  SHIM_* variables instead of dumping the validation traceback.
- .env.example reworked to match (commented-out token line so an empty
  value can't shadow the fallback).

The only required setting for a same-box deploy is now
SHIM_SUBSONIC_PASSWORD.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
Two issues surfaced running the shim locally:

- app/config.py and shim/config.py loaded env_file=".env" relative to
  the cwd, so launching from a subdirectory silently lost all settings
  (the pydantic ValidationError the user hit from inside shim/). Anchor
  env_file to the repo root via Path(__file__).parents[1] in both.
- `hum shim` silently started Hum because the hum console script ignored
  argv. main() now rejects stray args with a hint pointing at hum-shim.

Test isolation: pinning env_file to the real repo .env means unit tests
that construct settings directly can pick up a dev's local values
(e.g. SHIM_ALLOW_PLAIN_PASSWORD=true). test_subsonic_auth builds with
_env_file=None; test_shim_config uses an autouse fixture pointing at a
nonexistent .env. Adds tests/unit/test_cli.py.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
…laylists)

Adds the spec §3.2 browse surface to the shim (decision #6 = Search +
Playlists):

- getMusicFolders → one 'Hum' folder
- getArtists/getIndexes → valid empty catalog (search-centric source has
  no static artist tree; discovery is search + playlist drill-in)
- getAlbumList2 → empty (Hum has no catalog/history)
- getPlaylists → empty (Hum can't enumerate; reached via search → getPlaylist)
- getPlaylist/getAlbum (pl: id) → expand /api/playlist/{id} into entries/songs
  with vid: ids, so stream works unchanged
- search3 now surfaces kind==playlist hits as drill-in albums
- getCoverArt handles pl: ids; HumClient.fetch_art dispatches video/playlist
  and sources art (remembered thumb → first item thumb → i.ytimg) without
  ever triggering a pytubefix extraction (spec §3.5)

New: HumPlaylistItem/HumPlaylistInfo models, ids.playlist_id/artist_id,
HumClient.playlist(). Art cache re-keyed by full sid. Tests: test_browse.py
(endpoints via fake), test_hum_client.py (real adapter via httpx.MockTransport).

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
Shim-side favourites since Hum stores nothing (spec §3.3/§9.4):

- shim/store.py: FavouritesStore over an atomic-write JSON file
  (SHIM_DATA_DIR, default <repo>/.shim-data, gitignored), plus a bounded
  recently-emitted metadata cache (remember/recall).
- star only gets an id from Subsonic, so title/artist are recalled from
  the seen-cache that search3/getAlbum/getPlaylist populate as they emit
  — getStarred2 renders names without re-fetching (no extraction).
- star/unstar/getStarred2/getStarred endpoints; scrobble accepts and
  no-ops (no Hum history to write).

Tests: test_store.py (store + seen-cache bounds/persistence/corruption),
test_favourites.py (endpoint roundtrips). Autouse conftest fixture
isolates the store per test.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
- getCoverArt size= now selects a ytimg variant (default/mqdefault/
  hqdefault) instead of ignoring it — no decode, no new dependency.
  Capped at hqdefault since sddefault/maxresdefault 404 for many uploads
  and a 404 would surface as 'no cover art'. Signed Hum thumbnails pass
  through unresized.
- _raise_for_hum_error now surfaces Hum's error code + message and maps
  client-side conditions (403/404/410/415/422/451 — unplayable, region-
  locked, live-not-supported) to Subsonic 70, transient/5xx to 0, rather
  than collapsing everything non-404 to a generic error.

Tests cover variant rewriting (incl. non-ytimg passthrough) and the
status→code mapping.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
stream_ffmpeg now distinguishes a client disconnect from a real transcode
failure and leaves a diagnostic for the latter:

- Capture stderr (ffmpeg runs at -loglevel error, low volume) and log a
  bounded tail when ffmpeg exits non-zero for a reason other than our own
  teardown — no more silently truncated streams.
- Detect disconnect via GeneratorExit/CancelledError (Starlette closing the
  body iterator), not via returncode: returncode is frequently still None
  after stdout EOF even on failure, so the old check would have mislabeled
  genuine failures as self-kills and suppressed the warning.
- Suppress ProcessLookupError when killing an already-exited process.

Tests fake the subprocess (success, non-zero-exit-logs-stderr, and
disconnect-kills-without-warning) since CI has no ffmpeg.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
Optional path giving Sonos a seek bar: materialize the AAC remux to a
complete +faststart .m4a in an on-disk cache, then serve it via Starlette
FileResponse (Content-Length + 206 Range). Falls back to the streaming
pipe (mode a) if materialization fails.

- shim/mediacache.py: MediaCache — size-bounded (SHIM_TEMP_CACHE_MB),
  LRU-by-mtime eviction, atomic .part→publish, per-key single-flight.
- transcode.remux_file_args (+faststart, file dest) + transcode.materialize
  (run-to-completion, logs stderr tail on failure).
- stream() uses the cached file when SHIM_SEEKABLE_REMUX is on.

Off by default — the streaming pipe stays the low-latency default;
materialization adds first-byte latency (whole track transcoded first,
cheap for -c copy). Flip on and validate during the Sonos phase, since
whether Sonos prefers FileResponse 206 is a hardware-test question.

Tests: MediaCache (produce/cache/evict/failure/sanitize) and the seekable
endpoint (full body + Accept-Ranges, 206 partial, pipe fallback).

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
…t errors

Integration harness (tests/shim/integration, -m integration, deselected by
default) hits a RUNNING shim over HTTP so it exercises the real Hum token,
ffmpeg, and YouTube: search3 returns songs; a stream decodes as audio
(ffprobe). Reads creds from .env via python-dotenv (bypassing the in-process
test dummies); skips cleanly when the shim, Hum, or ffprobe is absent.

On its first run it caught a real bug: with Hum stopped, the shim returned
HTTP 500 because httpx.RequestError (ConnectError) escaped — _raise_for_hum_error
only handled non-200 *responses*, not transport failures. Added HumClient._get
wrapping all upstream GETs (search/video/playlist/cover-art) to map
httpx.RequestError → Subsonic error envelope (code 0, 'Hum unreachable').

Tests: transport-error mapping via httpx.MockTransport raising ConnectError.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
…ision

Sync docs/SONOS_SPEC.md to the implemented state:
- build-status banner in §7; phase checkmarks (0/1/2 + Phase 5 code items).
- decision #6 resolved → Search + Playlists (empty artist/album catalog).
- record queue-prefetch as investigated-but-not-built (no queue visibility
  in the per-track stream contract) and seekable remux as off-by-default.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
Amperfy (and other Subsonic clients) GET the base URL to confirm a server
is present before hitting /rest/. The shim only had /rest/* and /health, so
GET / returned 404 and aborted Auto-Detect — the GET / 404 storm seen when a
user tried to connect. Add a small liveness payload at /.

(Explicit Subsonic/Subsonic-legacy modes were unaffected — they hit /rest/
directly — but Auto-Detect needs a non-404 root.)

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
Root cause of 'login failed' despite ping returning 200: Amperfy's requests
omit f=json, so per the Subsonic protocol it expects XML — but the shim always
returned JSON. Amperfy got a 200 it couldn't parse and rejected the server.

- subsonic.py: add Subsonic XML rendering (scalars→attributes, dicts→child
  elements, lists→repeated elements; xmlns + status/version/type on the root)
  and per-request format negotiation via a ContextVar.
- auth dependency records the format from the f param (runs before handlers,
  so ok_response picks JSON vs XML); exception handlers read f off the request
  directly (robust for pre-dependency validation errors).
- f=json still returns JSON; absent/other → XML (the protocol default).
- endpoints unchanged (still call ok_response(payload)); return type widened
  to Response.

Tests: XML ping/search3/error shapes + f=json still JSON; existing no-credential
tests pinned to f=json.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
bonob's Playlist container calls getPlaylists, which the shim returned empty
(Hum can't enumerate playlists). Populate it from a curated set:
- SHIM_PINNED_PLAYLISTS: comma-separated YouTube playlist IDs
- starred playlists (pl: ids in the favourites store)

Summaries (name/songCount/owner) resolve concurrently via /api/playlist;
a failed lookup (e.g. deleted playlist) is dropped, not fatal. getPlaylist
already expands a pl: id into tracks.

This gives Sonos (via bonob) a clean, user-curated browse surface with no
auto-accumulation — unlike Amperfy's search-result library caching.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
bonob exposes a top-level 'Internet Radio' container (verified in
subsonic_music_library.ts/smapi.ts → getInternetRadioStations), so live music
gets a clean, non-library home in Sonos:

- getInternetRadioStations maps Hum /api/radio live hits → stations whose
  streamUrl points at the shim's new unauthenticated /radio/{id} endpoint
  (Sonos fetches it directly, no Subsonic creds).
- /radio/{id} (shim/radio.py) resolves the live HLS manifest via Hum and pipes
  a continuous mp3 via ffmpeg (live_radio_args). HumVideoDetails gains
  is_live/live_stream_url; HumClient gains radio()/live_manifest_url().
- SHIM_PUBLIC_URL sets the LAN-reachable base for station streamUrls.

Caveat (documented): live-stream → Sonos-radio playback is unverified and
needs a hardware test — the station listing + pipe are in place, but whether
Sonos accepts the output is the open question.

Tests: station mapping, unauthenticated stream pipes mp3, bad-id rejected,
live_radio_args shape.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
docs/SONOS_VALIDATION.md: step-by-step human/hardware validation the test
suite can't cover — .env setup, stack start (+ stale-process gotcha), curl
smoke test, integration suite, Amperfy (Phase 1/2 exit), bonob+Sonos
(Phase 3) with a per-shelf expectation table, the unverified Internet Radio
playback check, and optional seekable-remux / S2 steps. Written so the work
can resume cold.

https://claude.ai/code/session_01V3GpXaL3pYFggGhXqUS1b2
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.

3 participants