Mirrors the TTS view's "configured -> locked" behavior. HandlePlayURL
already prefers the server-side --service-url over the client value, so
when it's set the browser field's edits are ignored anyway; reflect that
by rendering it read-only with a note, and editable only as a fallback
when no --service-url is configured. (Play URL has no SSRF: the URL is
handed to the speaker, not fetched by soundtouch-web.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CodeQL flagged "uncontrolled data used in network request": the
soundtouch-web TTS proxy built its outbound request URL from the
client-supplied serviceUrl, letting any LAN caller use the endpoint as an
SSRF proxy. The proxy target must be the operator-configured --service-url.
- handler: use only app.ServiceURL; drop the client-supplied serviceUrl
field and fallback.
- web TTS view: show the configured service URL read-only with an
explanation of why it can't be edited here (Play URL differs — its URL
is handed to the speaker, not fetched by soundtouch-web, so no SSRF).
- api.speak no longer sends serviceUrl.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The TTS speak/config endpoints were under /mgmt (Basic-Auth protected),
but the soundtouch-web proxy and CLI authenticated with their own
mgmt-password default (empty) while the service defaults to "change_me!",
so speaking from -web returned 401.
This was also inconsistent: the Google API key is configured via the
unauthenticated /setup/settings, and Play URL already proxies to /setup,
so gating only TTS playback behind mgmt auth made no sense. Move
/mgmt/tts/{speak,config} to /setup/tts/{speak,config} (LAN-trust, like
the rest of the setup surface), rename the handlers accordingly, and drop
the now-unused mgmt-credential plumbing from soundtouch-web and the CLI
tts command.
Verified: POST /setup/tts/speak now reaches the handler without auth
(502 only because the test speaker IP is unreachable; previously 401).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Google Cloud TTS API key (and app_key / provider / language / voice /
volume) can now be set in the service settings page, persisted to
settings.json, and applied at runtime — same model as Spotify/Amazon
(CLI/env wins at startup, else persisted; secrets masked as "***" over
the wire; a save triggers ReinitTTSService without a restart).
To keep the settings page from bloating as integrations grow, Spotify,
Amazon, and Google Cloud TTS are now collapsible <details> panels under
an "Integrations" heading, each showing an Active/Saved/Inactive badge in
its summary that stays visible when collapsed. Adding a future provider
(e.g. Apple Music) is now just another panel.
Provider construction moved from cmd initTTSService into
handlers.Server.ReinitTTSService so the UI can re-apply changes; the
tts-provider flag default is now empty (empty => translate) so a value
saved in the UI can take effect.
Also: the soundtouch-web TTS source view now shows the AfterTouch service
URL with an override (shared with Play URL via localStorage), and
/api/device-speak accepts a serviceUrl override, mirroring Play URL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds text-to-speech that synthesizes higher-quality audio (Google Cloud
TTS) and plays it on a speaker via the /speaker endpoint. Because Cloud
TTS returns audio bytes (not a fetchable URL), the service caches the
clip and hosts it at GET /media/tts/{id}, mirroring the "ding" endpoint,
then points the speaker at that local URL.
The design is a pluggable Provider interface (pkg/service/tts) wrapping
two modes:
- translate: hands the speaker the (undocumented) Google Translate URL
directly (no credentials), reusing models.BuildTranslateTTSURL.
- google-cloud: REST API key auth (no SDK/gRPC), bytes cached locally.
Surfaces:
- service: POST /mgmt/tts/speak, GET /mgmt/tts/config, GET /media/tts/{id};
configured via TTS_PROVIDER / TTS_GOOGLE_API_KEY / TTS_LANGUAGE /
TTS_VOICE / TTS_APP_KEY / TTS_VOLUME.
- CLI: `soundtouch-cli tts speak` (calls the service with mgmt Basic Auth).
- web: a "TTS" source view (like Play URL / TuneIn), proxied to the
service via /api/device-speak/{id}.
The /speaker app_key requirement and model limitations still apply; see
docs/content/docs/reference/SPEAKER-ENDPOINT.md.
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>
The power, mute, shuffle, and repeat buttons used Unicode emoji (⏻ 🔇🔀🔁) which Android/mobile browsers render through the OS emoji font
with platform-specific colour styling, ignoring CSS color entirely.
This caused them to look like colourful emoji badges rather than flat
monochrome controls.
Replace each with an inline SVG using stroke/fill="currentColor" so
they inherit the button's text colour automatically — flat in both light
and dark mode, and correctly inverted when a button is in its active
(accent-background) state without any extra CSS filter.
The .ctrl-btn rule gains display:inline-flex + align-items:center to
vertically centre both text-character (⏮ ⏸ ⏭) and SVG content
consistently. The .volume-icon label in the volume row switches from
an emoji span to the same currentColor SVG at 16 px.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The speaker WebSocket was cycling every ~65 s because the gorilla pong
handler was never set, so the 60-second read deadline in readLoop fired
after each ping cycle (30 s interval + 5 s reconnect = ~65 s loop).
Setting a pong handler that extends the deadline on every pong response
keeps the connection alive indefinitely during quiet periods.
After any (re)connect the Go server now immediately fetches current
device state via HTTP, because Bose speakers do not replay WebSocket
events on new connections — anything that changed during a disconnect
window would otherwise stay stale until the next speaker-side event.
A 30-second periodic HTTP poll per device is added as a backstop for
Spotify Connect track changes that the SoundTouch API does not surface
as nowPlayingUpdated WebSocket events.
On the browser side, track identity (TrackID / ContentItem.Location) is
added to the NowPlaying timer effect deps so the local counter resets
whenever the track changes regardless of start position, and the time
label is clamped to the song total to prevent "4:17 / 4:09" overruns.
Co-Authored-By: Claude Sonnet 4.6 <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>
- Add scripts/raspberry-pi/install-web.sh: mirrors install.sh but for
the stateless soundtouch-web binary (no privileged ports, no data dir,
no HTTPS). Default port 8080; override via HTTP_PORT at install time.
- Add GET /health to soundtouch-web (handler + mount); returns
{"status":"ok","version":"…"} — used by the installer's health check
and by monitoring.
- Update scripts/raspberry-pi/README.md to document both installers side
by side (installation, config, service management, updates, removal).
- Bump default VERSION to v0.97.0 in all three installer scripts
(install.sh, install-web.sh, on-device-install/install.sh).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two complementary ways to save what's currently playing to a preset slot
without leaving the web UI:
★ Star button (Now Playing card)
A semi-transparent star appears in the top-right corner of the Now
Playing card whenever a device is selected and something is playing.
Clicking it opens a slot picker (1–6); selecting a slot calls
POST /api/control/{id}/storepreset?id={slot}. The star turns gold
when the current ContentItem is already mapped to at least one preset,
matching the preset list by Source + Location. An outside-click
closes the picker without saving.
+ button (preset tiles)
While content is playing each of the six preset tiles shows a small +
button on hover. Clicking it saves directly to that slot — no picker
needed. The button cycles through + → ✓ → (reset) states with
a 1.5 s success flash and shows ✗ briefly on error.
Backend (handler.go):
New "storepreset" case in handleControlAction dispatches to
handleStorePreset, which validates the ?id= query param (1-6) and
calls device.Client.StoreCurrentAsPreset(presetID).
Frontend (api.js):
storePreset(deviceId, slotId) helper added.
CSS (app.css):
.preset-slot-wrap wrapper + .preset-save-btn styles for the + button,
source-specific --slot-color custom properties for border accents,
.now-playing-fav-wrap / .now-playing-fav-btn / .now-playing-fav-overlay
for the star button and its popover (right-aligned, z-index: 50).
position: relative added to .now-playing so the star can be absolutely
positioned without being clipped by .track-info overflow: hidden.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The guard was accidentally placed in HandlePlayRadioBrowser instead of
HandleDevicePlay in the initial fix commit, then removed from there by
the build-fix commit — leaving HandleDevicePlay with no guard at all.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous edit accidentally inserted the TUNEIN placeholder guard
into HandlePlayRadioBrowser, which uses a different req struct without
SourceAccount/Source fields, breaking the build.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Speakers echo back the source name as SourceAccount when no real
credential is set (e.g. SourceAccount="TUNEIN" for a TUNEIN source).
HandleDevicePlay was forwarding this verbatim, causing the speaker to
try authenticating with the source name as a TuneIn account and
returning INVALID_SOURCE.
Clear SourceAccount when it equals Source; preserve it when it differs
(real credentials such as Spotify or STORED_MUSIC UUIDs).
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>
- Sets up Dependabot for JS dependency updates
- Adds GitHub workflow for automated static dependency updates
- Creates update script for Preact and other static JS libraries
- Updates Preact to latest version via new automation
- 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
ConnectDeviceWebSocket was a one-shot: connect, wait for disconnect,
log, return. Once the device-side WebSocket died (idle timeout, blip,
speaker reboot), the goroutine ended and conn.WebSocket stayed
pointing at the (now-dead) client — which made the duplicate-spawn
guard `if device.WebSocket == nil` at the five callsites in
handler.go correctly skip spawning, but with nothing else trying to
reconnect, the speaker's status flow froze for the rest of the
process's lifetime. The browser kept receiving status_update
messages on the 5 s ticker (HandleWebSocket), but every payload
carried the same stale data the service last knew.
Symptom: load the page, NowPlaying shows fresh state; some minutes
later, the speaker switches presets or tracks but NowPlaying never
updates — even though playback itself works because those are
one-shot HTTP calls that don't depend on the WebSocket.
Fix: wrap the connect-and-wait in a for-loop with exponential
backoff (1 s → 30 s cap, reset on every successful connect). The
goroutine now lives for the device entry's lifetime; conn.WebSocket
is updated on each successful reconnect and never cleared, so the
existing guards keep working without spawning duplicate loops.
Pre-existing main bug — preserved by the relocation, surfaced when
testing the rebased branch. Fix is contained to the one function;
behaviour is byte-identical for the happy path (one connect, no
disconnect ever).
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails). golangci-lint run ./... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stations still showed a dim ▶ inside the .tunein-item-arrow span
while programs (with the new pill button from 34d4692) showed a
circled play button. Two different play affordances side by side
looked accidental.
Now every item with a playback link renders the same pill button,
and the arrow span carries only the drill-in chevron. Per item type:
Stations (play only) pill ▶
Programs (navigate + play) pill ▶ + chevron ›
Genres (navigate only) chevron ›
The pill stops event propagation, so clicking it triggers play
without bubbling to the row's navigate handler — that lets row
clicks keep drilling into programs while the button cuts straight
to "play latest episode."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Preact TuneInBrowser hid the play affordance whenever an item
also had a navigate link. TuneIn programs have BOTH (drill into
episodes + play latest episode, after backend PR #317), so the
button never appeared on program rows — only the chevron.
Old vanilla UI showed both. Restored:
- navigate(item) keeps its current behaviour (path wins for row
clicks, falls through to play if there's no path) — that lets
pure-leaf items (stations) still play on whole-row click.
- New explicit .tunein-play-btn rendered conditionally when an item
has BOTH a navigate link and a playback link. Stops event
propagation so clicking it triggers play (device picker overlay)
instead of bubbling to the row's navigate handler.
- CSS: pill-shaped 32px button using the same --accent / --text-dim
tokens the rest of the UI uses; hover state swaps to --accent /
--accent-fg to avoid same-on-same contrast in either theme.
The chevron stays as the row's "drill in" indicator for any
navigable item, including programs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports app's commit b040c8a. Five new handlers + five new routes for
master/slave stereo-pair and multi-room management; the Zone.js
frontend was already shipped in the Preact swap.
HandleGetZone GET /api/zone/{id}
Returns zone info enriched with member names and role flags
(isMaster / isSlave / isStandalone) computed from the perspective
of the queried device. Each member carries IP, hwID, and friendly
name so the frontend can render readable rows.
HandleZoneAdd POST /api/zone/{id}/add/{slaveId}
Adds a slave to the zone where {id} is or becomes the master.
Standalone master gets a fresh ZoneRequest; existing zone is
extended via ToZoneRequest + AddMember.
HandleZoneRemove POST /api/zone/{id}/remove/{slaveId}
Removes a named slave from the master's existing zone.
HandleZoneDissolve POST /api/zone/{id}/dissolve
Issues a single-member ZoneRequest so the master goes standalone.
HandleZoneLeave POST /api/zone/{id}/leave
Slave-side leave: looks up the master via findIPByHwID using the
slave's current zone info, then dispatches RemoveMember against
the master's client (the speaker protocol requires the master to
own the SetZone call).
Translation notes:
- All handlers go through app.GetDevice(id) instead of direct
app.Devices[id] access — matches main's encapsulated-registry
refactor (post-base on main, see registry_test.go).
- findIPByHwID iterates via app.DeviceSnapshot() instead of ranging
over the raw map.
- pkg/client (GetZone/SetZone) and pkg/models (ZoneInfo/ZoneRequest/
Member/NewZoneRequest/AddMember/RemoveMember/IsStandalone/
ToZoneRequest) API surface confirmed unchanged from app's base —
verbatim function calls.
Risk recap (per the earlier audit): this was flagged medium-risk
because of pkg/client zone-API drift. Verified clean — all symbols
exist with the expected signatures on current main. The #252 stereo-
pair work that landed on main was in cmd/soundtouch-cli/cmd_group.go
(parallel POST to LEFT and RIGHT), which doesn't intersect with the
single-master SetZone pattern these handlers use.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./pkg/service/soundtouchweb/... ./cmd/soundtouch-web/...
0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports app's commit 3122c4e to the package layout. Two new handlers
and route registrations; the frontend was already shipped in the
Preact swap.
HandleDeviceRecents GET /api/device-recents/{id}
Returns the speaker's /recents list as APIResponse{Success,Data}.
Backs the Recents.js component (lazy-loaded list under the
device-detail view; hides itself when the device returns no
recents).
HandleDevicePlay POST /api/device-play/{id}
Generic content-item player. Decodes a {source,type,location,
sourceAccount,itemName,containerArt,isPresetable} JSON body into
a *models.ContentItem and runs Client.SelectContentItem. Used by
Recents.js to replay items the speaker reports, regardless of
source — TuneIn, Spotify, AUX, etc. Different from HandlePlayTuneIn
which is TuneIn-specific.
Translation note: app's bodies used app.Devices[id] directly; main's
registry is encapsulated behind GetDevice/AddDevice/TouchDevice (see
the post-base refactor that introduced registry_test.go), so this
commit uses app.GetDevice(id) instead. Same lookup, just through the
maintained API.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./pkg/service/soundtouchweb/... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replays app's commit-1 architectural restructure onto current main —
mechanical move only, behaviour preserved verbatim. main.go shrinks
from 333 to ~190 lines; the binary now orchestrates lifecycle and
flag handling, the package owns the WebApp's responsibilities.
Moves (no logic change vs the previous main.go bodies):
main.go addDevice → (*WebApp).AddDeviceByHost in discovery.go
main.go discoverDevices → (*WebApp).DiscoverDevices in discovery.go
main.go setupRoutes → (*WebApp).Mount(r, ds) in mount.go
inline serveIndex closure → (*WebApp).serveIndex in mount.go
New helper:
soundtouchweb.NewDiscoveryService(interfaceName) wraps
config.LoadFromEnv + cfg adjustments + NewUnifiedDiscoveryService.
Single source of truth for the web UI's discovery settings;
identical to the inline wiring main.go used to do.
main.go still owns (kept verbatim, post-base on main):
- --port / --bind / --interface / --devices flags
- resolveBindAddr (NIC-name → IP resolution for --bind)
- defaultDiscoveryInterface (--bind ↔ --interface defaulting)
- Startup goroutine sequence: broadcast start → preseed loop
(AddDeviceByHost for each --devices entry) → DiscoverDevices →
broadcast complete + device list
- http.ListenAndServe
Behaviour parity checklist:
- Routes registered: identical set (see Mount). /api/discover still
reuses the startup discoveryService instance, same as before.
- Preseeded --devices still added BEFORE the mDNS/UPnP sweep, so the
UI doesn't briefly show empty for hosts that come from --devices.
- Discovery interface still pinned via --interface (or inherited from
--bind), threaded through NewDiscoveryService.
- Static FS still served at /static/*, SPA fallback at / /devices
/device/* still hits the same index.html.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings forward the frontend rewrite from the `app` branch
(6723515 + later refinements) onto the relocated package layout.
The Go side untouched — main.go's orchestration, discovery, routes,
and handlers all remain. Only the static-asset layer changes.
Frontend (lives in pkg/service/soundtouchweb/static/):
- index.html (importmap-driven, ES modules, no build step)
- css/app.css (CSS-custom-property design system, dark by default)
- js/api.js (typed-ish fetch wrappers)
- js/app.js (Preact App shell: routing, toast, websocket reconnect)
- js/components/{DeviceList,NowPlaying,Controls,Presets,Sources,
Recents,Zone,TuneInBrowser}.js
- img/favicon.{ico,svg}
- lib/{preact,preact-hooks,htm}.module.js (vendored ES modules)
Backend wiring:
- New pkg/service/soundtouchweb/embed.go exports `StaticFS embed.FS`
via `//go:embed static`. main.go drops its own `//go:embed` and
consumes `soundtouchweb.StaticFS` instead, so the static tree
lives alongside the handlers it serves.
- cmd/soundtouch-web/static/{index.html,css/app.css,js/app.js} are
deleted; the old `cmd/soundtouch-web/static/` directory is empty
now and removed entirely.
Path rename vs. app branch:
- app's importmap pointed at `/static/vendor/preact*.js` and the
vendor files were never committed because `.gitignore:44 vendor/`
silently masked them. Renamed to `/static/lib/` to escape the
global rule and `git add`-ed the three modules.
Known regressions vs. main's vanilla UI (acceptable for this commit;
flag in review or follow-up if any matter):
- Per-card power toggle on the device list — Preact only exposes
power inside the device-detail view, not on the list card.
- WebSocket reconnect uses `location.reload()` after 5s; main had
exponential backoff. Functional, simpler, less elegant.
- Theme icon control absent (Preact UI is dark-only via CSS vars;
no light-mode toggle).
Features carried over and confirmed at the route-shape level:
device list / device detail / nowPlaying / volume+key+power controls
/ presets / sources / TuneIn search + browse + play / discovery /
toasts / WebSocket status updates.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails). golangci-lint run ./... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical relocation only — zero semantic change. Sets up the package
layout that the future Preact-UI rewrite (branch `app`) wants, while
preserving every line of main's current logic. Subsequent commits will
land the additive parts (frontend rewrite, recents, zones, bass control)
on top of this clean base.
Moves (`git mv`, content unchanged except package decl):
cmd/soundtouch-web/handlers/handlers.go → pkg/service/soundtouchweb/handler.go
cmd/soundtouch-web/handlers/handlers_test.go → pkg/service/soundtouchweb/handler_test.go
cmd/soundtouch-web/handlers/websocket.go → pkg/service/soundtouchweb/websocket.go
cmd/soundtouch-web/handlers/registry_test.go → pkg/service/soundtouchweb/registry_test.go
cmd/soundtouch-web/webtypes/types.go → pkg/service/soundtouchweb/webtypes/types.go
cmd/soundtouch-web/webtypes/types_test.go → pkg/service/soundtouchweb/webtypes/types_test.go
cmd/soundtouch-web/webtypes/status_test.go → pkg/service/soundtouchweb/webtypes/status_test.go
cmd/soundtouch-web/static/img/tunein-{dark,mono}.svg → pkg/service/soundtouchweb/static/img/
Adjustments:
- `package handlers` → `package soundtouchweb` in the 4 moved handler-tier
files (plus their package-doc comments).
- Import paths rewritten in cmd/soundtouch-web/{main.go,spa_test.go} and
in the moved files themselves: cmd/soundtouch-web/{handlers,webtypes}
→ pkg/service/soundtouchweb/{,webtypes}.
- `handlers.` selector renamed to `soundtouchweb.` in the callers.
- `.golangci.yml` errcheck waiver extended from `cmd/.*\.go` to also
cover `pkg/service/soundtouchweb/.*\.go`. Same code that the
cmd-tier waiver applied to; same waiver follows it. Documented as
a carry-over with the intent to tighten in a follow-up review.
Not changed:
- `cmd/soundtouch-web/main.go` keeps the `//go:embed static` pointing at
the still-vanilla `cmd/soundtouch-web/static/`. The frontend rewrite
(Preact UI) lands in a later commit; this one is mechanical.
- `cmd/soundtouch-web/resolve_bind_addr_test.go` stays put — it tests
main.go-local flag plumbing.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>