Selecting a Radio Browser station from the player UI returned HTTP 500
and the speaker dropped to INVALID_SOURCE. The play path sent the speaker
a ContentItem with source="URL" and an absolute location
(https://all.api.radio-browser.info/soundtouch/stations/byuuid/<uuid>).
source="URL" makes the speaker fetch that location as a raw audio stream,
but the URL returns station JSON, not audio, so the speaker rejects it.
"URL" was never a real source: it is not in the speaker's sourceprovider
registry and never persisted in any datastore. The rest of the stack is
already built for the native RADIO_BROWSER source (BMX registry provider
39 with base URL .../soundtouch, a seeded RADIO_BROWSER source, marge
classification, and the documented relative location form). Working
RADIO_BROWSER items use source="RADIO_BROWSER" with a relative
location="/stations/byuuid/<uuid>", which the speaker resolves against
the registry base URL and plays directly.
- stations.ResolveContentItem: emit source=RADIO_BROWSER for the provider
- RadioBrowserSearch: emit the relative /stations/byuuid/<uuid> playback
href so the speaker prepends the registry base URL
- marge classifier: match the relative /stations/byuuid/ segment (covers
both the relative and legacy absolute forms)
- tests updated to assert the native source + relative location
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the BMX TuneIn integration tests independent of the live TuneIn
(radiotime.com) service, the same way Spotify/Amazon are already mocked.
- pkg/service/bmx: the TuneIn upstream base URLs become configurable vars with
a SetTuneInEndpoints(opmlBase, apiBase) setter that also registers the host in
the outbound allowlist. Defaults are unchanged (real radiotime hosts), so
production behaviour is identical; tests can redirect to a mock.
- cmd/soundtouch-service: new --tunein-opml-url / --tunein-api-url flags
(TUNEIN_OPML_URL / TUNEIN_API_URL) wired through to SetTuneInEndpoints.
- cmd/mock-tunein + pkg/testutils/tunein: a mock TuneIn server serving Tune.ashx
(stream URLs) and describe.ashx (name/logo) with RFC-5737 values; unmocked
endpoints 404 so a test needing them fails loudly.
- docker-compose.ci.yml: add the tunein-mock service and point the service at it.
- tunein_playback_station.http now asserts the mock-served stream URL + name,
proving the path is offline. tunein_favorite.http covers the local-only
favorite add/remove (202).
- TUNEIN-MOCK-MISSING.md lists the upstream captures still needed (episode /
navigate / search) before those routes can be mocked + tested.
make test-http-client: 61 requests, 0 failed. golangci-lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TuneIn's Tune.ashx returns several stream URLs per station (different
bitrates/CDNs) so a speaker can fail over when one is dead. TuneInPlayback
parsed the full list but forwarded only urls[0], wrapping a single URL in
the audio.streams[] array. When TuneIn listed a dead variant first (e.g.
station s56857 / NDR 2 Niedersachsen, whose aac/low 404s while mp3/128
plays), the speaker had no fallback and dead-ended retrying the 404.
Add BuildCustomStreamResponseFromURLs to emit one Stream per candidate in
provider order (top-level StreamUrl mirrors urls[0] for compatibility),
have the single-URL BuildCustomStreamResponse delegate to it, and forward
the full slice from TuneInPlayback. The other single-URL callers
(PlayCustomStream, the custom-stream handler) are unchanged.
Confirmed on real hardware: the speaker now fails over from the 404'd
aac/low to the working mp3/128 stream and plays.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a provider-neutral station orchestration layer and expose it in the
CLI so TuneIn and Radio Browser search work consistently without
depending on the speaker's (dead) cloud search. Substance of #338.
- pkg/service/stations: new package with Search/SearchNext/Navigate/
ResolveContentItem/Play over both providers; centralises the
SourceAccount placeholder guard.
- soundtouchweb: the six TuneIn/Radio Browser handlers become thin
adapters over the new package (behaviour preserved; bmxpkg retained
for HandlePlayURL).
- bmx/radiobrowser: add offset/cursor pagination
(RadioBrowserSearchPage + RadioBrowserSearchNext) mirroring the
TuneIn opaque-cursor pattern; BmxNext only on full pages.
- marge: classifyLearnedSource gains a provider-39 (RADIO_BROWSER)
case + classifyAsRadioBrowser helper (candidate fix for #334
INVALID_SOURCE; location-substring match still to be confirmed
against a real recording).
- cli: new `station search-radiobrowser` sibling and unified
`station find --provider tunein|radiobrowser [--more]`. The existing
generic device-side `station search --source` is kept unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a top-level "Play URL" view (nav icon: link) so users can paste an
arbitrary stream URL and play it on any discovered device — same
browse-globally-pick-device pattern as TuneIn and RadioBrowser.
- pkg/service/bmx: extract BuildOrionLocation (encode side), shared by
CLI and web handler; check json.Marshal error (errchkjson)
- cmd/soundtouch-cli: use bmxpkg.BuildOrionLocation instead of local
copy; merge dual LOCAL_INTERNET_RADIO branches to reduce cyclomatic
complexity (gocyclo)
- cmd/soundtouch-web: add --service-url / SERVICE_URL flag; expose it
in WebApp.ServiceURL
- soundtouchweb handler: HandlePlayURL wraps raw stream in Orion
location when ServiceURL is set (client-supplied fallback when not);
exposes service_url in /api/version for frontend pre-fill
- soundtouchweb mount: POST /api/play-url/{id}, GET /playurl SPA route
- frontend: PlayURL.js component with device-picker overlay; AfterTouch
URL persisted to localStorage, pre-filled from server when no override
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
testing.Short() would silently suppress the test even with
RADIOBROWSER_INTEGRATION=1 set, contradicting the skip message.
The env var opt-in is sufficient on its own.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The test dials all.api.radio-browser.info directly. When the upstream
TLS certificate expires the test fails and blocks the build — the local
codebase has no control over third-party certificate health.
Guard with testing.Short() and an opt-in env var so CI stays green and
the live-network test can still be run explicitly when needed.
Closes#412
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TuneIn's profiles API caps initial results at ~10 per container (Stations,
Shows, etc.) and exposes a Pivots.More.Url cursor for the remainder. This
change wires that cursor through the stack so users can load additional
results without leaving the search view.
- tuneInSearchSection now extracts Pivots.More.Url as bmx_next when
itemToken is present; absent for containers already at their limit
- TuneInSearchNext fetches the cursor URL, which returns a flat Items[]
(not nested containers), and maps Station/Program/Topic items using
the existing play/profile builders
- New GET /v1/search/next and /api/tunein/search/next endpoints with
matching handlers in both service paths
- TuneInBrowser: flat items state replaced with per-section sections
state; each section shows a header label and a Load more button when
a cursor is available; browse/navigate mode is unaffected
Relates to #336.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The b95bdae split changed BmxPlayback.Href to raw `Tune.ashx?id=…` URLs,
which the speaker's BMX module fetches directly — failing `IsItBose`,
sending no auth, and getting 401 from radiotime. Restore the v0.85.0
shape (`/v1/playback/{station|episodes}/{id}`) so playback flows back
through HandleTuneInPlayback. Also restore play-link emission for Topic
search results (single podcast episodes); `Tune.ashx?id=t<N>` accepts
them like station IDs, so the same path works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Refactors BMX service to support multiple radio providers
- Adds RadioBrowser.com API integration with search and browse
- Splits TuneIn logic into separate module for better organization
- Adds new web UI components for radio station discovery
- Includes new SVG icons for RadioBrowser branding
Add a `soundtouch-cli source tunein` subcommand that takes a TuneIn
guide ID and routes it through the right SelectContentItem shape —
`--station`, `--episode`, `--program`, or `--id` with prefix
auto-detect. The flag picks the ContentItem Type (`stationurl` for
stations/episodes, `tracklisturl` for programs) and the location
template, then enriches the now-playing metadata from TuneIn's describe
endpoint unless `--no-lookup` is set.
Program IDs (`p<N>`) are containers, not streams. The legacy OPML
`Tune.ashx?id=p<N>` returns `#STATUS: 400`, which pre-filter went out
to the speaker verbatim. Fix in three layers:
1. `parseTuneInStreamBody` filters `#`-prefixed comment lines out of
Tune.ashx responses and errors when nothing playable remains, so
a broken TuneIn reply surfaces as a real 500 instead of corrupting
the playback response.
2. `TuneInPlaybackPodcast` expands `p<N>` to its newest episode via
`api.radiotime.com/profiles/{id}/contents` (same JSON shape as
api.tunein.com; uses the radiotime mirror so all program traffic
stays on the host already in `allowedTuneInHosts`).
3. `tuneInSearchProfile` (Program search items) and
`TuneInNavigateProfile` (program detail hero) now emit
`BmxPlayback` links, so soundtouch-web renders play buttons on
program cards and on the profile hero — clicking either plays the
latest episode via the same backend expansion.
Tests pin the parser contracts (`#STATUS: 400` filter, program-contents
episode pick) and the navigate Program-only playback emission. CLI
resolver has table-driven coverage for kind selection, prefix
auto-detect, and conflicting-flag errors.
Endpoint contract + raw probe responses captured under
`_/i226/tunein-api-findings.md` and `_/i226/tunein-probe/` for future
reference.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #249 added "hls" unconditionally to TuneIn's Tune.ashx formats=
query. That regressed playback on the SoundTouch line: TuneIn returns
an .m3u8 HLS playlist for stations like K-LOVE (s33828), the speaker
can't parse it, blinks amber and falls silent. Verified that
firmware 27 on ST10 and ST20 ships the byte-identical Mozilla CCADB
bundle and validates the actual stream chain cleanly, so it isn't a
cert-expiry issue (#292's hypothesis) — the speaker simply has no
HLS support.
Changes:
- TuneInStream is now a builder, not a const: takes the station ID
plus a formats string (empty falls back to the new exported
DefaultTuneInStreamFormats = "mp3,aac,ogg" — matches the pre-#249
request shape).
- TuneInPlayback and TuneInPlaybackPodcast take the formats string.
- New Settings.TuneInStreamFormats string. Empty by default.
Operators with HLS-capable speakers can set it to
"mp3,aac,ogg,hls" — or any other comma-separated list — via
settings.json. The value is passed through verbatim; AfterTouch
does not validate the individual format tokens, so this is also
the right knob for trialling additional formats without code
changes.
- Two regression tests pin both the empty-uses-default contract
and the override-passes-through contract (with the whitespace-
trim sub-case) so PR #249-style regressions surface at
compile/test time.
The setting is settings.json-only (matches the existing pattern for
AllowInsecureUpstreamTLS / TrustForwardedHeaders / TrustedProxyCIDRs
which are also edit-the-file settings). UI surface can be a small
follow-up if reporters ask for it.
Example settings.json snippet to re-enable HLS (only if your
speaker can actually play it):
{
"server_url": "http://aftertouch.local:8000",
"tunein_stream_formats": "mp3,aac,ogg,hls"
}
Restart soundtouch-service after editing.
Related to https://github.com/gesellix/Bose-SoundTouch/issues/292.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Naive string concatenation (`rawURL + "&render=json"`) produced
malformed URLs when the input had no query string yet, or already
contained render=json. Replace with tuneInRenderJSONURI which parses and
sets the parameter cleanly. Also fix TuneIn search query encoding in the
self link and section href, and replace the http-prefix check for OPML
URIs with a proper host comparison.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>