Conversation
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
|
@copilot review spec |
Here's a cross-reference review of the spec against the codebase ( 🔴 §9 (open decision #6) — Subsonic auth is underdeterminedThe decision says "decide whether the shim validates or accepts" the credentials bonob sends. bonob's default is salted-MD5 token auth — it posts 🟡 §4 — Mode 1 container ordering should be invertedADTS ( 🟡 §3.4 —
|
…_live inversion, cache eviction formula, Subsonic auth specifics
- 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
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:Updated:
README.md— Enhanced API documentation:category=musicandlive=falsefilters to/api/search/api/radio,/api/hls/{id}.m3u8,/api/live/{id}/manifest.m3u8,/proxy/live-segment/{id}/api/video/{id}returns signedhls_urlfor AAC formats andlive_stream_urlfor live videos/api/debug/live/{id}/upstreamfor live-stream debuggingNotable Details
c68470e(Hum 0.1.0), resolving discrepancies in the original draftffmpeg -c copy) significantly reduces technical risk vs. the original mp3 re-encode plan