mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
@@ -5,6 +5,24 @@
|
||||
SOUNDTOUCH_HOSTNAME=soundtouch.local
|
||||
SOUNDTOUCH_VERSION=latest
|
||||
|
||||
# Stockholm frontend (used by make prepare-stockholm and by the Go service at startup)
|
||||
# BACKEND_URL is the base URL your speakers and browser can reach the service at.
|
||||
# Corresponds to SERVER_URL in the Go service.
|
||||
# BACKEND_URL=http://soundtouch.local:8000
|
||||
#
|
||||
# STREAMING_URL is used for streaming.bose.com rewrites (defaults to BACKEND_URL).
|
||||
# Set to $(BACKEND_URL)/marge only when routing through a soundcork backend.
|
||||
# STREAMING_URL=http://soundtouch.local:8000
|
||||
#
|
||||
# AUTH_SERVICE_URL is written into config.json as the auth endpoint (defaults to BACKEND_URL).
|
||||
# A trailing slash is added automatically; the JS appends paths like "oauth/account/..." directly.
|
||||
# AUTH_SERVICE_URL=http://soundtouch.local:8000
|
||||
#
|
||||
# STOCKHOLM_BASE_PATH mounts the Stockholm UI under a URL prefix, freeing / for the management UI.
|
||||
# The bridge API (/api/native/*, /api/http-proxy) remains at root regardless of this setting.
|
||||
# Defaults to /stockholm. Set to empty to serve at root.
|
||||
# STOCKHOLM_BASE_PATH=/stockholm
|
||||
|
||||
# Discovery Settings
|
||||
DISCOVERY_TIMEOUT=5s
|
||||
UPNP_ENABLED=true
|
||||
|
||||
@@ -98,3 +98,11 @@ pids
|
||||
|
||||
# dotenv environment variables file (but keep .env.example)
|
||||
!.env.example
|
||||
|
||||
# Stockholm frontend — generated by `make prepare-stockholm`, not committed
|
||||
stockholm/
|
||||
|
||||
!pkg/service/stockholm/
|
||||
|
||||
# Stockholm source zip — large binary, place manually at stockholm_zip/stockholm.zip
|
||||
stockholm_zip/*.zip
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
Thank you for your interest in contributing to the Bose SoundTouch API Client! This project aims to provide a comprehensive, reliable, and well-tested Go library for controlling Bose SoundTouch devices.
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
All contributions are welcome — large or small:
|
||||
|
||||
- **Code suggestions** — bug fixes, new features, refactoring, performance improvements.
|
||||
- **Documentation updates** — README, guides, examples, troubleshooting notes, inline doc comments.
|
||||
- **Bug fixes** — even just a clear reproducer in an issue is a real contribution.
|
||||
- **Donations** — if the project kept a speaker (or several) of yours alive past the Bose cloud shutdown and you want to give back, [GitHub Sponsors](https://github.com/sponsors/gesellix) is open. No expectation; everything in this repo stays MIT regardless.
|
||||
|
||||
By submitting a code or documentation contribution you agree to license it under MIT. The detailed guides below cover the mechanics.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
@@ -15,6 +26,7 @@ Thank you for your interest in contributing to the Bose SoundTouch API Client! T
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [Device Testing](#device-testing)
|
||||
- [Community](#community)
|
||||
- [Support the Project](#support-the-project)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
@@ -465,6 +477,14 @@ Contributors will be:
|
||||
- **Mentioned in release notes** for significant contributions
|
||||
- **Credited in documentation** where appropriate
|
||||
|
||||
## Support the Project
|
||||
|
||||
If you want to support the maintenance effort beyond code:
|
||||
|
||||
[](https://github.com/sponsors/gesellix)
|
||||
|
||||
Sponsorship is entirely optional. Code, docs, and bug reports remain the most useful contributions for the project itself.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Go Documentation](https://golang.org/doc/)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Dockerfile.stockholm — builds the Stockholm frontend preparation image.
|
||||
#
|
||||
# This image clones krahl/soundcork-stockholm-app, installs the required tools
|
||||
# (prettier, patch, unzip, jq), and is used exclusively to run the entrypoint
|
||||
# preparation step that extracts and patches the Stockholm frontend.
|
||||
#
|
||||
# Java is NOT included — we stop before `exec java`.
|
||||
#
|
||||
# Usage (see Makefile targets build-stockholm-image / prepare-stockholm):
|
||||
#
|
||||
# docker build --build-arg STOCKHOLM_APP_REF=main \
|
||||
# -f Dockerfile.stockholm -t soundcork-stockholm-app .
|
||||
#
|
||||
# docker run --rm \
|
||||
# -v "$PWD/stockholm_zip:/app/stockholm_zip:ro" \
|
||||
# -v "$PWD/stockholm:/app/stockholm" \
|
||||
# --entrypoint bash soundcork-stockholm-app \
|
||||
# -c 'awk "/^exec java/{exit} {print}" /app/docker-entrypoint.sh | bash'
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG STOCKHOLM_APP_REF=main
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
jq \
|
||||
unzip \
|
||||
nodejs \
|
||||
npm \
|
||||
patch && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g prettier@3.8.3 && npm cache clean --force
|
||||
|
||||
RUN git clone --depth 1 --branch "${STOCKHOLM_APP_REF}" \
|
||||
https://github.com/krahl/soundcork-stockholm-app /app
|
||||
|
||||
WORKDIR /app
|
||||
@@ -1,4 +1,7 @@
|
||||
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help screenshots
|
||||
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm
|
||||
|
||||
# Load .env if present (simple KEY=VALUE format, no shell quoting)
|
||||
-include .env
|
||||
|
||||
# Go parameters
|
||||
GOCMD=go
|
||||
@@ -31,6 +34,22 @@ BUILD_DIR=./build
|
||||
# Build flags: strip debug info/DWARF for smaller binaries, remove local paths for reproducibility
|
||||
BUILDFLAGS=-trimpath -ldflags="-s -w"
|
||||
|
||||
# Stockholm frontend preparation (see Dockerfile.stockholm and docs/stockholm-port-guide.md)
|
||||
# STOCKHOLM_APP_REF can be overridden to pin a specific commit: make build-stockholm-image STOCKHOLM_APP_REF=<sha>
|
||||
STOCKHOLM_IMAGE ?= soundcork-stockholm-app
|
||||
STOCKHOLM_APP_REF ?= main
|
||||
STOCKHOLM_ZIP_DIR ?= $(CURDIR)/stockholm_zip
|
||||
STOCKHOLM_DIR ?= $(CURDIR)/stockholm
|
||||
# URLs baked into stockholm/json/config.json during prepare-stockholm.
|
||||
# The Go service rewrites these again at startup using SERVER_URL / MARGE_URL,
|
||||
# so these only matter for static-file-only deployments or when pre-baking is desired.
|
||||
# Default to localhost:8000 (matches the Go service default).
|
||||
BACKEND_URL ?= http://localhost:8000
|
||||
# STREAMING_URL defaults to BACKEND_URL (no /marge suffix — set to $(BACKEND_URL)/marge for soundcork).
|
||||
STREAMING_URL ?= $(BACKEND_URL)
|
||||
# AUTH_SERVICE_URL defaults to BACKEND_URL; override to point at a different auth endpoint.
|
||||
AUTH_SERVICE_URL ?= $(BACKEND_URL)
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-web build-examples build-favicon-gen build-backup
|
||||
@@ -214,6 +233,18 @@ dev-service-proxy: build-service
|
||||
fi
|
||||
PYTHON_BACKEND_URL=$(PROXY_URL) $(BUILD_DIR)/$(SERVICE_NAME)
|
||||
|
||||
# Run the service with the Stockholm frontend enabled. Requires that
|
||||
# `make prepare-stockholm` has been run at least once (the check below
|
||||
# avoids re-running the Docker container on every dev launch).
|
||||
dev-service-stockholm: build-service
|
||||
@if [ ! -f "$(STOCKHOLM_DIR)/index.html" ]; then \
|
||||
echo "Error: Stockholm not prepared at $(STOCKHOLM_DIR)."; \
|
||||
echo "Run 'make prepare-stockholm' first (needs stockholm_zip/stockholm.zip)."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Starting development service with Stockholm enabled from $(STOCKHOLM_DIR)..."
|
||||
STOCKHOLM_DIR=$(STOCKHOLM_DIR) $(BUILD_DIR)/$(SERVICE_NAME)
|
||||
|
||||
dev-discover: build-cli
|
||||
@echo "Running device discovery..."
|
||||
$(BUILD_DIR)/$(BINARY_NAME) -discover
|
||||
@@ -329,6 +360,69 @@ docker-build:
|
||||
@echo "Building Docker image..."
|
||||
docker build --target soundtouch-service -t soundtouch-service .
|
||||
|
||||
# Stockholm frontend preparation.
|
||||
# Requires: Docker, internet access (clones github.com/krahl/soundcork-stockholm-app).
|
||||
# No pre-built image is published; the image must be built locally before running prepare-stockholm.
|
||||
build-stockholm-image:
|
||||
@echo "Building Stockholm preparation image (clones upstream, installs prettier/patch)..."
|
||||
docker build \
|
||||
--build-arg STOCKHOLM_APP_REF=$(STOCKHOLM_APP_REF) \
|
||||
-f Dockerfile.stockholm \
|
||||
-t $(STOCKHOLM_IMAGE) \
|
||||
.
|
||||
|
||||
# Extracts and patches the Stockholm frontend using the upstream container image.
|
||||
# Requires: build-stockholm-image to have been run, and stockholm_zip/stockholm.zip to be present.
|
||||
# The resulting stockholm/ directory is used by the soundtouch-service at runtime.
|
||||
prepare-stockholm:
|
||||
@mkdir -p "$(STOCKHOLM_DIR)"
|
||||
@[ -f "$(STOCKHOLM_ZIP_DIR)/stockholm.zip" ] || { \
|
||||
echo "Error: $(STOCKHOLM_ZIP_DIR)/stockholm.zip not found."; \
|
||||
echo "Download the Stockholm zip and place it at stockholm_zip/stockholm.zip first."; \
|
||||
exit 1; }
|
||||
docker run --rm \
|
||||
-e BACKEND_URL=$(BACKEND_URL) \
|
||||
-e STREAMING_URL=$(STREAMING_URL) \
|
||||
-e AUTH_SERVICE_URL=$(AUTH_SERVICE_URL) \
|
||||
-v "$(STOCKHOLM_ZIP_DIR):/app/stockholm_zip:ro" \
|
||||
-v "$(STOCKHOLM_DIR):/app/stockholm" \
|
||||
--entrypoint bash \
|
||||
$(STOCKHOLM_IMAGE) \
|
||||
-c 'awk "/^exec java/{exit} {print}" /app/docker-entrypoint.sh | bash'
|
||||
@# Patch update-urls.sh: replace the hardcoded ${BACKEND_URL}/marge with
|
||||
@# ${STREAMING_URL:-${BACKEND_URL}} so the streaming URL is configurable and
|
||||
@# defaults to BACKEND_URL (no /marge suffix) rather than the soundcork convention.
|
||||
@script="$(STOCKHOLM_DIR)/json/update-urls.sh"; \
|
||||
awk '{ gsub(/\$$\{BACKEND_URL\}\/marge/, "$${STREAMING_URL:-$${BACKEND_URL}}"); print }' \
|
||||
"$$script" > "$$script.tmp" && mv "$$script.tmp" "$$script"
|
||||
@# Restore config.json from the backup that update-urls.sh created.
|
||||
@# The Go service rewrites URLs at startup via RewriteConfigURLs, so we start
|
||||
@# from the original Bose URLs rather than whatever update-urls.sh produced.
|
||||
@[ ! -f "$(STOCKHOLM_DIR)/json/backup.json" ] || \
|
||||
cp "$(STOCKHOLM_DIR)/json/backup.json" "$(STOCKHOLM_DIR)/json/config.json"
|
||||
@# Patch browse.js: guard against empty browse-path array so that
|
||||
@# funcObj.browse.getPath() returning undefined does not throw when the user
|
||||
@# has not browsed yet (causes "Now playing error: topLevel" console spam and
|
||||
@# aborts the now-playing update handler).
|
||||
@sed -i.bak \
|
||||
-e 's/: (l()\.topLevel/: ((l() || {}).topLevel/' \
|
||||
-e 's/var a = l()\.topLevel,/var a = (l() || {}).topLevel,/' \
|
||||
-e 's/E() === 0 || funcObj\.browse\.getPath()\.topLevel/E() === 0 || (funcObj.browse.getPath() || {}).topLevel/' \
|
||||
"$(STOCKHOLM_DIR)/js/browse.js" && \
|
||||
rm -f "$(STOCKHOLM_DIR)/js/browse.js.bak"
|
||||
@# Patch bridge JS: replace hardcoded /api/* paths with __stockholmBase-prefixed
|
||||
@# versions so the bridge works when Stockholm is mounted under a base path.
|
||||
@# browser_http_proxy.js declares the proxy URL as a top-level constant;
|
||||
@# without patching it, requests from a /stockholm/* page hit /api/http-proxy
|
||||
@# directly and 404 because the proxy is mounted under the base path.
|
||||
@# Also fix resolveWebviewUrl to include the base path when resolving relative URLs.
|
||||
@python3 scripts/patch-stockholm-bridge.py \
|
||||
"$(STOCKHOLM_DIR)/js/browser_http_proxy.js" \
|
||||
"$(STOCKHOLM_DIR)/js/browser_native_bridge.js" \
|
||||
"$(STOCKHOLM_DIR)/js/app_comm.js" \
|
||||
"$(STOCKHOLM_DIR)/setup/js/app_comm.js"
|
||||
@echo "Stockholm frontend prepared at $(STOCKHOLM_DIR)"
|
||||
|
||||
docker-run-host:
|
||||
@echo "Running Docker container..."
|
||||
@echo "Note: --network host is used for discovery (Linux only). For macOS/Windows use port mapping."
|
||||
@@ -362,6 +456,7 @@ help:
|
||||
@echo " dev - Build and show CLI help"
|
||||
@echo " dev-service - Build and run service locally"
|
||||
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
|
||||
@echo " dev-service-stockholm - Build and run service with Stockholm frontend (requires prior 'make prepare-stockholm')"
|
||||
@echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)"
|
||||
@echo " dev-discover - Build and run device discovery"
|
||||
@echo " dev-info - Build and get device info (HOST=ip required)"
|
||||
@@ -386,6 +481,9 @@ help:
|
||||
@echo " docker-build - Build Docker image"
|
||||
@echo " docker-run-host - Run container with host networking (Linux discovery)"
|
||||
@echo " docker-run-ports - Run container with port mapping (macOS/Windows/No discovery)"
|
||||
@echo " build-stockholm-image - Build Stockholm prep image (requires Docker + internet)"
|
||||
@echo " prepare-stockholm - Extract and patch Stockholm frontend (requires build-stockholm-image"
|
||||
@echo " and stockholm_zip/stockholm.zip; see docs/stockholm-port-guide.md)"
|
||||
@echo " help - Show this help message"
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
[](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
> Independent project. Not affiliated with or endorsed by Bose Corporation.
|
||||
> Independent project. **Not affiliated with, endorsed by, sponsored
|
||||
> by, or otherwise connected to Bose Corporation.** See
|
||||
> [Disclaimer](#disclaimer) for the full statement.
|
||||
|
||||
## Context: Cloud Shutdown
|
||||
|
||||
@@ -20,7 +22,7 @@ See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVI
|
||||
|
||||
A local server that replaces the Bose cloud ("AfterTouch"). Once your speaker is redirected to it, you have full control without any Bose cloud dependency. The built-in web UI at `http://localhost:8000` handles all setup — no config files needed to get started.
|
||||
|
||||
If you want to run a server for this - no problem. The service is small enough to run on the SoundTouch itself. See the [On-Device Installer](./scripts/on-device-install/README.md) for instructions.
|
||||
If you don't want to run a server for this - no problem. The service is small enough to run on the SoundTouch itself. See the [On-Device Installer](./scripts/on-device-install/README.md) for instructions.
|
||||
|
||||
**Two scenarios:**
|
||||
|
||||
@@ -122,8 +124,35 @@ See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests welcome — code, documentation, bug reports, and feature ideas all land in the same place. By submitting a contribution you agree to license it under MIT. For significant changes please open an issue first to discuss the approach. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide.
|
||||
|
||||
## Support the project
|
||||
|
||||
If this toolkit kept a speaker (or several) of yours alive past the Bose cloud shutdown and you want to give back, [GitHub Sponsors](https://github.com/sponsors/gesellix) is open. No expectation — everything in this repo stays MIT regardless.
|
||||
|
||||
[](https://github.com/sponsors/gesellix)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is an independent open-source project. **Bose** and **SoundTouch**
|
||||
are registered trademarks of Bose Corporation in the United States and
|
||||
other countries. This project is **not affiliated with, endorsed by,
|
||||
sponsored by, or otherwise connected to** Bose Corporation.
|
||||
|
||||
The toolkit exists solely to restore functionality of Bose SoundTouch
|
||||
speakers after the official cloud service shutdown on May 6, 2026.
|
||||
Reverse engineering for the sole purpose of interoperability is
|
||||
permitted under [EU Directive 2009/24/EC, Article 6](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32009L0024)
|
||||
("Decompilation"), and comparable provisions in other jurisdictions.
|
||||
|
||||
The optional Stockholm frontend integration (`STOCKHOLM_DIR`) requires
|
||||
the user to supply the Stockholm web-app sources themselves; no Bose
|
||||
code is redistributed in this repository.
|
||||
|
||||
The software is provided AS IS, without warranty. Use at your own risk.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
SoundTouch is a trademark of Bose Corporation.
|
||||
|
||||
@@ -413,9 +413,11 @@ func handlePresetEvent(event *models.PresetUpdatedEvent, verbose bool) {
|
||||
for _, preset := range presets.Preset {
|
||||
fmt.Printf(" 📻 Preset %d:", preset.ID)
|
||||
|
||||
if preset.ContentItem != nil {
|
||||
fmt.Printf(" %s", preset.ContentItem.ItemName)
|
||||
fmt.Printf(" (%s)", preset.ContentItem.Source)
|
||||
// IsEmpty catches both <preset/> and INVALID_SOURCE
|
||||
// placeholders; using the nil-safe helpers below means the
|
||||
// inner Printf never dereferences a nil ContentItem.
|
||||
if !preset.IsEmpty() {
|
||||
fmt.Printf(" %s (%s)", preset.GetDisplayName(), preset.GetSource())
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
@@ -177,23 +177,36 @@ func getPresets(c *cli.Context) error {
|
||||
|
||||
fmt.Printf("Device Presets:\n")
|
||||
|
||||
if len(presets.Preset) == 0 {
|
||||
// Filter out placeholder presets the firmware emits for unconfigured
|
||||
// slots (issue #308): self-closing <preset/> after factory reset,
|
||||
// or <ContentItem source="INVALID_SOURCE"/> on healthy devices.
|
||||
// IsEmpty covers both shapes; accessing fields like ContentItem.Source
|
||||
// directly on the first shape panics.
|
||||
configured := make([]models.Preset, 0, len(presets.Preset))
|
||||
|
||||
for _, p := range presets.Preset {
|
||||
if !p.IsEmpty() {
|
||||
configured = append(configured, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(configured) == 0 {
|
||||
fmt.Printf(" No presets configured\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf(" Configured Presets:\n")
|
||||
|
||||
for _, preset := range presets.Preset {
|
||||
for _, preset := range configured {
|
||||
fmt.Printf(" %d. %s\n", preset.ID, preset.GetDisplayName())
|
||||
fmt.Printf(" Source: %s\n", preset.ContentItem.Source)
|
||||
fmt.Printf(" Source: %s\n", preset.GetSource())
|
||||
|
||||
if preset.ContentItem.SourceAccount != "" && preset.ContentItem.SourceAccount != preset.ContentItem.Source {
|
||||
fmt.Printf(" Account: %s\n", preset.ContentItem.SourceAccount)
|
||||
if account := preset.GetSourceAccount(); account != "" && account != preset.GetSource() {
|
||||
fmt.Printf(" Account: %s\n", account)
|
||||
}
|
||||
|
||||
if preset.ContentItem.Location != "" {
|
||||
fmt.Printf(" Location: %s\n", preset.ContentItem.Location)
|
||||
if location := preset.GetLocation(); location != "" {
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
}
|
||||
|
||||
// Show preset creation time if available
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Package main — `soundtouch-cli source tunein` subcommand.
|
||||
//
|
||||
// Convenience shortcut for the verbose `source content --source TUNEIN
|
||||
// --type … --location …` pattern. Picks the right Type + location template
|
||||
// from the TuneIn guide-ID prefix, optionally fetches name + artwork from
|
||||
// TuneIn's describe endpoint, then calls the same SelectContentItem path
|
||||
// the generic `source content` command uses.
|
||||
//
|
||||
// Implements #226.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// tuneInKind captures the three guide-ID shapes the SoundTouch firmware
|
||||
// distinguishes; each picks a different Bose `/v1/playback/...` location
|
||||
// template and a different ContentItem Type.
|
||||
type tuneInKind struct {
|
||||
flag string // CLI flag name (`station`, `episode`, `program`)
|
||||
prefix string // single-letter guide-ID prefix (`s`, `e`, `p`)
|
||||
location string // printf template, %s = guide ID
|
||||
itemType string // ContentItem.Type the speaker expects
|
||||
humanName string // user-facing kind label for log lines
|
||||
}
|
||||
|
||||
var tuneInKinds = []tuneInKind{
|
||||
{flag: "station", prefix: "s", location: "/v1/playback/station/%s", itemType: "stationurl", humanName: "live station"},
|
||||
{flag: "episode", prefix: "e", location: "/v1/playback/episode/%s", itemType: "stationurl", humanName: "podcast episode"},
|
||||
{flag: "program", prefix: "p", location: "/v1/playback/episodes/%s", itemType: "tracklisturl", humanName: "podcast program"},
|
||||
}
|
||||
|
||||
// resolveTuneInKind picks a kind from the CLI flags. Exactly one of
|
||||
// --station / --episode / --program must be set, OR --id with a prefix we
|
||||
// recognise. Returns the kind plus the bare guide ID.
|
||||
func resolveTuneInKind(c *cli.Context) (*tuneInKind, string, error) {
|
||||
// Explicit kind flags take precedence over --id.
|
||||
var picked *tuneInKind
|
||||
|
||||
var id string
|
||||
|
||||
for i, k := range tuneInKinds {
|
||||
v := c.String(k.flag)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if picked != nil {
|
||||
return nil, "", fmt.Errorf("only one of --station, --episode, --program may be set")
|
||||
}
|
||||
|
||||
picked = &tuneInKinds[i]
|
||||
id = v
|
||||
}
|
||||
|
||||
if picked != nil {
|
||||
return picked, strings.TrimSpace(id), nil
|
||||
}
|
||||
|
||||
// Fall back to --id with prefix auto-detect.
|
||||
raw := strings.TrimSpace(c.String("id"))
|
||||
if raw == "" {
|
||||
return nil, "", fmt.Errorf("one of --station, --episode, --program, or --id is required")
|
||||
}
|
||||
|
||||
if raw == "" {
|
||||
return nil, "", fmt.Errorf("--id is empty")
|
||||
}
|
||||
|
||||
for i, k := range tuneInKinds {
|
||||
if strings.HasPrefix(raw, k.prefix) {
|
||||
return &tuneInKinds[i], raw, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("--id %q has no recognised TuneIn prefix; use --station/--episode/--program explicitly", raw)
|
||||
}
|
||||
|
||||
// playTuneIn is the action wired into `soundtouch-cli source tunein`.
|
||||
func playTuneIn(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kind, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := c.String("name")
|
||||
artwork := c.String("artwork")
|
||||
|
||||
// Optional metadata enrichment — only fetch if the user hasn't already
|
||||
// supplied both, and they haven't asked us to skip it.
|
||||
if !c.Bool("no-lookup") && (name == "" || artwork == "") {
|
||||
fetchedName, fetchedLogo, lookupErr := bmx.TuneInDescribeMeta(id)
|
||||
if lookupErr != nil {
|
||||
// Non-fatal: the speaker can resolve the title itself; just
|
||||
// note the failure so an operator sees what went wrong.
|
||||
fmt.Printf(" Note: TuneIn describe lookup failed (%v); proceeding without enrichment.\n", lookupErr)
|
||||
} else {
|
||||
if name == "" {
|
||||
name = fetchedName
|
||||
}
|
||||
|
||||
if artwork == "" {
|
||||
artwork = fetchedLogo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
// Fall back to a sensible non-empty default so the speaker's
|
||||
// now-playing UI doesn't show a blank source label.
|
||||
name = "TuneIn"
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: kind.itemType,
|
||||
Location: fmt.Sprintf(kind.location, id),
|
||||
ItemName: name,
|
||||
ContainerArt: artwork,
|
||||
IsPresetable: true,
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Playing TuneIn "+kind.humanName, clientConfig.Host, clientConfig.Port)
|
||||
fmt.Printf(" ID: %s\n", id)
|
||||
fmt.Printf(" Location: %s\n", contentItem.Location)
|
||||
fmt.Printf(" Type: %s\n", contentItem.Type)
|
||||
fmt.Printf(" Name: %s\n", contentItem.ItemName)
|
||||
|
||||
if contentItem.ContainerArt != "" {
|
||||
fmt.Printf(" Artwork: %s\n", contentItem.ContainerArt)
|
||||
}
|
||||
|
||||
if err := client.SelectContentItem(contentItem); err != nil {
|
||||
return fmt.Errorf("failed to select TuneIn content: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("TuneIn content selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// newCtx wires a *cli.Context with the kind-selection flags the resolver
|
||||
// reads, plus whatever values the test wants set. Empty-string values are
|
||||
// the default (flag not provided).
|
||||
func newCtx(t *testing.T, kv map[string]string) *cli.Context {
|
||||
t.Helper()
|
||||
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
for _, name := range []string{"station", "episode", "program", "id"} {
|
||||
fs.String(name, "", "")
|
||||
}
|
||||
|
||||
for k, v := range kv {
|
||||
if err := fs.Set(k, v); err != nil {
|
||||
t.Fatalf("fs.Set(%q, %q): %v", k, v, err)
|
||||
}
|
||||
}
|
||||
|
||||
return cli.NewContext(nil, fs, nil)
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_Station(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"station": "s14991"})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != "station" || k.itemType != "stationurl" {
|
||||
t.Errorf("wrong kind: %+v", k)
|
||||
}
|
||||
|
||||
if id != "s14991" {
|
||||
t.Errorf("wrong id: %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_Episode(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"episode": "e789012"})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != "episode" || k.itemType != "stationurl" {
|
||||
t.Errorf("wrong kind: %+v", k)
|
||||
}
|
||||
|
||||
if id != "e789012" {
|
||||
t.Errorf("wrong id: %q", id)
|
||||
}
|
||||
|
||||
if !strings.Contains(k.location, "/v1/playback/episode/") {
|
||||
t.Errorf("wrong location template: %q", k.location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_Program(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"program": "p123456"})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != "program" || k.itemType != "tracklisturl" {
|
||||
t.Errorf("wrong kind: %+v", k)
|
||||
}
|
||||
|
||||
if id != "p123456" {
|
||||
t.Errorf("wrong id: %q", id)
|
||||
}
|
||||
|
||||
if !strings.Contains(k.location, "/v1/playback/episodes/") {
|
||||
t.Errorf("wrong location template: %q", k.location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_IDPrefixAutoDetect(t *testing.T) {
|
||||
cases := []struct {
|
||||
id string
|
||||
wantFlag string
|
||||
}{
|
||||
{"s14991", "station"},
|
||||
{"e789012", "episode"},
|
||||
{"p123456", "program"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.id, func(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"id": tc.id})
|
||||
|
||||
k, id, err := resolveTuneInKind(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if k.flag != tc.wantFlag {
|
||||
t.Errorf("auto-detect picked %q; want %q", k.flag, tc.wantFlag)
|
||||
}
|
||||
|
||||
if id != tc.id {
|
||||
t.Errorf("id round-tripped wrong: got %q want %q", id, tc.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_NoFlags(t *testing.T) {
|
||||
c := newCtx(t, nil)
|
||||
|
||||
_, _, err := resolveTuneInKind(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no flags are set")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "required") {
|
||||
t.Errorf("error message should mention required flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_ConflictingFlags(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"station": "s14991", "episode": "e789012"})
|
||||
|
||||
_, _, err := resolveTuneInKind(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when conflicting flags are set")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "only one of") {
|
||||
t.Errorf("error message should mention exclusivity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuneInKind_UnknownPrefix(t *testing.T) {
|
||||
c := newCtx(t, map[string]string{"id": "x999"})
|
||||
|
||||
_, _, err := resolveTuneInKind(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown ID prefix")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "no recognised TuneIn prefix") {
|
||||
t.Errorf("error message should explain prefix mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1054,6 +1054,43 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tunein",
|
||||
Usage: "Play a TuneIn station / episode / program by guide ID (#226)",
|
||||
Action: playTuneIn,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "station",
|
||||
Usage: "TuneIn live-station guide ID (e.g. s14991)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "episode",
|
||||
Usage: "TuneIn single-episode guide ID (e.g. e789012)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "program",
|
||||
Usage: "TuneIn podcast/program guide ID (e.g. p123456)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "id",
|
||||
Usage: "TuneIn guide ID; kind auto-detected from s/e/p prefix",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Override the display name (skips name lookup)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "artwork",
|
||||
Usage: "Override the artwork URL (skips artwork lookup)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-lookup",
|
||||
Usage: "Skip the TuneIn describe lookup; send the bare ContentItem",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "availability",
|
||||
Usage: "Show service availability",
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -373,6 +374,17 @@ func main() {
|
||||
Value: "local",
|
||||
EnvVars: []string{"PREFERRED_SOURCE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-dir",
|
||||
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
|
||||
EnvVars: []string{"STOCKHOLM_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-base-path",
|
||||
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
|
||||
Value: "/stockholm",
|
||||
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
config := loadConfig(c)
|
||||
@@ -468,7 +480,20 @@ func main() {
|
||||
|
||||
startDeviceDiscovery(server)
|
||||
|
||||
r := setupRouter(server)
|
||||
var stockholmHandler *stockholm.Handler
|
||||
|
||||
if config.stockholmDir != "" {
|
||||
sh, shErr := stockholm.New(config.stockholmDir, config.dataDir, config.serverURL, config.stockholmBasePath)
|
||||
if shErr != nil {
|
||||
log.Printf("Warning: Failed to initialise Stockholm handler: %v", shErr)
|
||||
} else {
|
||||
stockholmHandler = sh
|
||||
|
||||
log.Printf("Stockholm frontend enabled from %s", config.stockholmDir)
|
||||
}
|
||||
}
|
||||
|
||||
r := setupRouter(server, stockholmHandler)
|
||||
|
||||
log.Printf("Go service starting on %s", config.serverURL)
|
||||
|
||||
@@ -552,6 +577,8 @@ type serviceConfig struct {
|
||||
migrationEnabled bool
|
||||
migrationDryRun bool
|
||||
preferredSource string
|
||||
stockholmDir string
|
||||
stockholmBasePath string
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) serviceConfig {
|
||||
@@ -628,6 +655,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
migrationEnabled := c.Bool("migration-enabled")
|
||||
migrationDryRun := c.Bool("migration-dry-run")
|
||||
preferredSource := c.String("preferred-source")
|
||||
stockholmDir := c.String("stockholm-dir")
|
||||
stockholmBasePath := c.String("stockholm-base-path")
|
||||
|
||||
return serviceConfig{
|
||||
port: port,
|
||||
@@ -666,6 +695,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
migrationEnabled: migrationEnabled,
|
||||
migrationDryRun: migrationDryRun,
|
||||
preferredSource: preferredSource,
|
||||
stockholmDir: stockholmDir,
|
||||
stockholmBasePath: stockholmBasePath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,7 +883,7 @@ func startDeviceDiscovery(server *handlers.Server) {
|
||||
}()
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// TrustedRealIP must run before any handler that reads r.RemoteAddr —
|
||||
@@ -881,8 +912,11 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
// reach it without a reboot.
|
||||
r.Post("/setup/peer-probe/{deviceId}", server.HandlePeerProbe)
|
||||
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.Path = "/media/favicon-braille.svg"
|
||||
server.HandleMedia()(w, r)
|
||||
// The favicon lives in the embedded web/img bundle, not under
|
||||
// static/media — HandleMedia would 404. HandleWeb serves from
|
||||
// webFS at its native path.
|
||||
r.URL.Path = "/web/img/favicon-braille.svg"
|
||||
server.HandleWeb()(w, r)
|
||||
})
|
||||
|
||||
r.Get("/media/*", server.HandleMedia())
|
||||
@@ -919,6 +953,16 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
|
||||
r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback)
|
||||
|
||||
// SiriusXM lives at the top level by the same convention. bmx_services.json
|
||||
// advertises baseUrl `{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`
|
||||
// (no /bmx/ prefix), so speakers reach this exact path under either
|
||||
// migration mode. The bare path returns the service descriptor (matches
|
||||
// soundcork main.py:805); sub-paths advertised by the descriptor's _links
|
||||
// (/availability, /navigate, /token, /logout) currently log + 404 so
|
||||
// future implementation work has visibility into real speaker calls.
|
||||
r.HandleFunc("/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter", server.HandleSiriusXMLiveAdapter)
|
||||
r.HandleFunc("/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*", server.HandleSiriusXMLiveAdapterSubpath)
|
||||
|
||||
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
|
||||
r.Route("/streaming", func(r chi.Router) {
|
||||
@@ -1140,8 +1184,19 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
|
||||
// Serve Stockholm setup wizard pages for paths not matched by the management API.
|
||||
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
|
||||
if stockholmHandler != nil {
|
||||
r.Get("/*", stockholmHandler.HandleStatic)
|
||||
r.Get("/", stockholmHandler.HandleStatic)
|
||||
}
|
||||
})
|
||||
|
||||
if stockholmHandler != nil {
|
||||
stockholmHandler.Mount(r)
|
||||
}
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
return r
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
func TestPrintRoutes(t *testing.T) {
|
||||
// Initialize a minimal server to get the router
|
||||
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
|
||||
r := setupRouter(server)
|
||||
r := setupRouter(server, nil)
|
||||
|
||||
var routes []string
|
||||
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
@@ -128,7 +128,7 @@ func TestPUTRenameRoutesToLocalHandler(t *testing.T) {
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false)
|
||||
r := setupRouter(server)
|
||||
r := setupRouter(server, nil)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
|
||||
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
@@ -32,6 +36,8 @@ GET /bmx/tunein/v1/playback/station/{stationID} handlers.(
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /ced/* handlers.(*Server).HandleCedStatic
|
||||
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
|
||||
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
|
||||
GET /docs/* handlers.(*Server).HandleDocs-fm
|
||||
@@ -89,8 +95,14 @@ GET /streaming/sourceproviders handlers.(
|
||||
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /v1/blacklist/{deviceId} setupRouter
|
||||
GET /web/* setupRouter.(*Server).HandleWeb
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
HEAD /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
OPTIONS /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
PATCH /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
@@ -103,6 +115,8 @@ POST /bmx/tunein/v1/favorite/{stationID} handlers.(
|
||||
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
|
||||
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
|
||||
POST /core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
|
||||
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
|
||||
POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm
|
||||
POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
|
||||
@@ -155,7 +169,11 @@ POST /streaming/support/customersupport handlers.(
|
||||
POST /streaming/support/power_on handlers.(*Server).HandleMargePowerOn-fm
|
||||
POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
PUT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
PUT /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeUpdateDevice-fm
|
||||
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -17,18 +18,34 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebApp holds the application state and dependencies
|
||||
// WebApp holds the application state and dependencies.
|
||||
//
|
||||
// The device registry (devices map + devicesMu) is encapsulated:
|
||||
// callers go through GetDevice / DeviceSnapshot / AddDevice /
|
||||
// TouchDevice / DeviceCount instead of touching the map directly.
|
||||
// This prevents the concurrent-map-read/write panic that would
|
||||
// otherwise be reachable any time an HTTP handler runs while
|
||||
// discovery or the /api/discover endpoint is registering devices.
|
||||
type WebApp struct {
|
||||
Devices map[string]*webtypes.DeviceConnection
|
||||
devicesMu sync.RWMutex
|
||||
devices map[string]*webtypes.DeviceConnection
|
||||
|
||||
Upgrader websocket.Upgrader
|
||||
WSClients map[*websocket.Conn]bool
|
||||
WSMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// DeviceEntry pairs a device id with its connection. Used by
|
||||
// DeviceSnapshot so callers can iterate without holding the lock.
|
||||
type DeviceEntry struct {
|
||||
ID string
|
||||
Device *webtypes.DeviceConnection
|
||||
}
|
||||
|
||||
// NewWebApp creates a new WebApp instance for SPA mode
|
||||
func NewWebApp() *WebApp {
|
||||
return &WebApp{
|
||||
Devices: make(map[string]*webtypes.DeviceConnection),
|
||||
devices: make(map[string]*webtypes.DeviceConnection),
|
||||
WSClients: make(map[*websocket.Conn]bool),
|
||||
Upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||
@@ -36,17 +53,89 @@ func NewWebApp() *WebApp {
|
||||
}
|
||||
}
|
||||
|
||||
// GetDevice returns the device for id and whether it exists.
|
||||
func (app *WebApp) GetDevice(id string) (*webtypes.DeviceConnection, bool) {
|
||||
app.devicesMu.RLock()
|
||||
defer app.devicesMu.RUnlock()
|
||||
|
||||
device, ok := app.devices[id]
|
||||
|
||||
return device, ok
|
||||
}
|
||||
|
||||
// DeviceSnapshot returns a list of (id, *DeviceConnection) pairs taken
|
||||
// under a single read lock. Callers can iterate the result without
|
||||
// holding any registry lock. Devices added or removed after the call
|
||||
// are not reflected; the pointers themselves remain valid because
|
||||
// nothing deletes from the underlying map today.
|
||||
func (app *WebApp) DeviceSnapshot() []DeviceEntry {
|
||||
app.devicesMu.RLock()
|
||||
defer app.devicesMu.RUnlock()
|
||||
|
||||
out := make([]DeviceEntry, 0, len(app.devices))
|
||||
for id, device := range app.devices {
|
||||
out = append(out, DeviceEntry{ID: id, Device: device})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// DeviceCount returns the number of registered devices at call time.
|
||||
func (app *WebApp) DeviceCount() int {
|
||||
app.devicesMu.RLock()
|
||||
defer app.devicesMu.RUnlock()
|
||||
|
||||
return len(app.devices)
|
||||
}
|
||||
|
||||
// AddDevice atomically registers conn under id when id is not already
|
||||
// known. If id existed, its LastSeen is bumped and AddDevice returns
|
||||
// false (the caller should discard conn). Returns true if conn was
|
||||
// inserted.
|
||||
func (app *WebApp) AddDevice(id string, conn *webtypes.DeviceConnection) bool {
|
||||
app.devicesMu.Lock()
|
||||
defer app.devicesMu.Unlock()
|
||||
|
||||
if existing, ok := app.devices[id]; ok {
|
||||
existing.LastSeen = time.Now()
|
||||
return false
|
||||
}
|
||||
|
||||
app.devices[id] = conn
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// TouchDevice bumps LastSeen for id if it exists; returns true if
|
||||
// found. Use this as a fast-path check before doing the network work
|
||||
// needed to construct a new DeviceConnection.
|
||||
func (app *WebApp) TouchDevice(id string) bool {
|
||||
app.devicesMu.Lock()
|
||||
defer app.devicesMu.Unlock()
|
||||
|
||||
existing, ok := app.devices[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
existing.LastSeen = time.Now()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleAPIDevices returns all devices as JSON
|
||||
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Return all devices as JSON
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
snapshot := app.DeviceSnapshot()
|
||||
devices := make(map[string]interface{}, len(snapshot))
|
||||
|
||||
for _, entry := range snapshot {
|
||||
devices[entry.ID] = map[string]interface{}{
|
||||
"info": entry.Device.DeviceInfo,
|
||||
"status": entry.Device.Status(),
|
||||
"lastSeen": entry.Device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +157,7 @@ func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
device, exists := app.GetDevice(deviceID)
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -88,7 +177,7 @@ func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"status": device.Status(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -107,7 +196,7 @@ func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
device, exists := app.GetDevice(deviceID)
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -318,7 +407,7 @@ func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
key := chi.URLParam(r, "key")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
device, exists := app.GetDevice(deviceID)
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -350,7 +439,7 @@ func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
device, exists := app.GetDevice(deviceID)
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -376,7 +465,7 @@ func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Requ
|
||||
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
device, exists := app.GetDevice(deviceID)
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -403,7 +492,7 @@ func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
|
||||
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
device, exists := app.GetDevice(deviceID)
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -444,12 +533,14 @@ func (app *WebApp) BroadcastDeviceList() {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
snapshot := app.DeviceSnapshot()
|
||||
devices := make(map[string]interface{}, len(snapshot))
|
||||
|
||||
for _, entry := range snapshot {
|
||||
devices[entry.ID] = map[string]interface{}{
|
||||
"info": entry.Device.DeviceInfo,
|
||||
"status": entry.Device.Status(),
|
||||
"lastSeen": entry.Device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,7 +689,7 @@ func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
device, exists := app.GetDevice(deviceID)
|
||||
if !exists {
|
||||
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -28,19 +28,15 @@ func createTestApp() *WebApp {
|
||||
},
|
||||
}
|
||||
|
||||
device := &webtypes.DeviceConnection{
|
||||
Client: nil, // No real client for unit tests
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{
|
||||
Volume: &models.Volume{ActualVolume: 50, MuteEnabled: false},
|
||||
Bass: &models.Bass{ActualBass: 0},
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
device := webtypes.NewDeviceConnection(nil, deviceInfo)
|
||||
device.SetStatus(&webtypes.DeviceStatus{
|
||||
Volume: &models.Volume{ActualVolume: 50, MuteEnabled: false},
|
||||
Bass: &models.Bass{ActualBass: 0},
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
})
|
||||
|
||||
app.Devices["test-device"] = device
|
||||
app.AddDevice("test-device", device)
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -59,13 +55,9 @@ func TestNewWebApp(t *testing.T) {
|
||||
if app == nil {
|
||||
t.Fatal("NewWebApp returned nil")
|
||||
}
|
||||
if app.Devices == nil {
|
||||
t.Fatal("Devices map not initialized")
|
||||
}
|
||||
|
||||
// At this point we know app and app.Devices are not nil
|
||||
if len(app.Devices) != 0 {
|
||||
t.Errorf("Expected empty devices map, got %d devices", len(app.Devices))
|
||||
if count := app.DeviceCount(); count != 0 {
|
||||
t.Errorf("Expected empty device registry, got %d devices", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,11 +541,9 @@ func BenchmarkHandleAPIDevices(b *testing.B) {
|
||||
// Add more devices for realistic benchmarking
|
||||
for i := 0; i < 10; i++ {
|
||||
deviceID := "device-" + string(rune('0'+i))
|
||||
app.Devices[deviceID] = &webtypes.DeviceConnection{
|
||||
Client: &client.Client{},
|
||||
DeviceInfo: &models.DeviceInfo{Name: "Test Device " + deviceID},
|
||||
Status: webtypes.DeviceStatus{IsConnected: true},
|
||||
}
|
||||
conn := webtypes.NewDeviceConnection(&client.Client{}, &models.DeviceInfo{Name: "Test Device " + deviceID})
|
||||
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true})
|
||||
app.AddDevice(deviceID, conn)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// Package handlers contains tests for the device registry API on
|
||||
// WebApp (GetDevice, AddDevice, TouchDevice, DeviceSnapshot,
|
||||
// DeviceCount).
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func newRegistryDevice(name string) *webtypes.DeviceConnection {
|
||||
conn := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{Name: name})
|
||||
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true})
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
func TestAddDevice_Inserts(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
conn := newRegistryDevice("first")
|
||||
|
||||
if !app.AddDevice("host-1", conn) {
|
||||
t.Fatal("AddDevice returned false on first insert")
|
||||
}
|
||||
|
||||
got, ok := app.GetDevice("host-1")
|
||||
if !ok {
|
||||
t.Fatal("GetDevice did not find the device after AddDevice")
|
||||
}
|
||||
|
||||
if got != conn {
|
||||
t.Errorf("GetDevice returned a different pointer than inserted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDevice_RejectsDuplicateAndBumpsLastSeen(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
original := newRegistryDevice("first")
|
||||
app.AddDevice("host-1", original)
|
||||
|
||||
originalSeen := original.LastSeen
|
||||
replacement := newRegistryDevice("second")
|
||||
|
||||
if app.AddDevice("host-1", replacement) {
|
||||
t.Fatal("AddDevice returned true on duplicate; expected false")
|
||||
}
|
||||
|
||||
got, _ := app.GetDevice("host-1")
|
||||
if got != original {
|
||||
t.Error("Duplicate AddDevice replaced the existing device pointer")
|
||||
}
|
||||
|
||||
if !got.LastSeen.After(originalSeen) {
|
||||
t.Error("Duplicate AddDevice did not bump LastSeen on existing device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchDevice(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
|
||||
if app.TouchDevice("missing") {
|
||||
t.Error("TouchDevice returned true for unknown id")
|
||||
}
|
||||
|
||||
conn := newRegistryDevice("first")
|
||||
app.AddDevice("host-1", conn)
|
||||
seenBefore := conn.LastSeen
|
||||
|
||||
if !app.TouchDevice("host-1") {
|
||||
t.Fatal("TouchDevice returned false for known id")
|
||||
}
|
||||
|
||||
if !conn.LastSeen.After(seenBefore) {
|
||||
t.Error("TouchDevice did not bump LastSeen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceSnapshotAndCount(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
|
||||
if got := app.DeviceCount(); got != 0 {
|
||||
t.Errorf("DeviceCount on empty app = %d; want 0", got)
|
||||
}
|
||||
|
||||
if snap := app.DeviceSnapshot(); len(snap) != 0 {
|
||||
t.Errorf("DeviceSnapshot on empty app = %v; want []", snap)
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
app.AddDevice(fmt.Sprintf("host-%d", i), newRegistryDevice(fmt.Sprintf("n%d", i)))
|
||||
}
|
||||
|
||||
if got := app.DeviceCount(); got != 5 {
|
||||
t.Errorf("DeviceCount after 5 adds = %d; want 5", got)
|
||||
}
|
||||
|
||||
snap := app.DeviceSnapshot()
|
||||
if len(snap) != 5 {
|
||||
t.Errorf("DeviceSnapshot len = %d; want 5", len(snap))
|
||||
}
|
||||
|
||||
// Spot-check that the snapshot ids match what we inserted.
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range snap {
|
||||
seen[entry.ID] = true
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
id := fmt.Sprintf("host-%d", i)
|
||||
if !seen[id] {
|
||||
t.Errorf("DeviceSnapshot missing %s", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryConcurrent exercises the registry from many goroutines
|
||||
// at once. Before the introduction of devicesMu this would either
|
||||
// panic with "fatal error: concurrent map read and map write" or be
|
||||
// flagged by the race detector. The test runs under `go test -race`
|
||||
// in CI so a future regression that re-exposes the underlying map
|
||||
// without locking would be caught here.
|
||||
func TestRegistryConcurrent(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
|
||||
const workers = 16
|
||||
|
||||
const opsPerWorker = 200
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers * 4)
|
||||
|
||||
// Writers: insert distinct ids across workers.
|
||||
for w := 0; w < workers; w++ {
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
|
||||
for i := 0; i < opsPerWorker; i++ {
|
||||
id := fmt.Sprintf("w%d-%d", worker, i)
|
||||
app.AddDevice(id, newRegistryDevice(id))
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
// Touchers: bump LastSeen on a shared id (which may or may not
|
||||
// exist yet — both branches are exercised).
|
||||
for w := 0; w < workers; w++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for i := 0; i < opsPerWorker; i++ {
|
||||
app.TouchDevice("shared")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Readers via snapshot.
|
||||
for w := 0; w < workers; w++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for i := 0; i < opsPerWorker; i++ {
|
||||
_ = app.DeviceSnapshot()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Readers via direct lookup.
|
||||
for w := 0; w < workers; w++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for i := 0; i < opsPerWorker; i++ {
|
||||
_, _ = app.GetDevice("shared")
|
||||
_ = app.DeviceCount()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Sanity check: every writer inserted opsPerWorker devices, plus
|
||||
// the "shared" entry was never AddDevice'd so should be absent.
|
||||
if got, want := app.DeviceCount(), workers*opsPerWorker; got != want {
|
||||
t.Errorf("DeviceCount after concurrent inserts = %d; want %d", got, want)
|
||||
}
|
||||
|
||||
if _, ok := app.GetDevice("shared"); ok {
|
||||
t.Error("shared device should not exist (only TouchDevice was called for it)")
|
||||
}
|
||||
}
|
||||
@@ -35,12 +35,14 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
app.WSMutex.Unlock()
|
||||
|
||||
// Send initial device list
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
snapshot := app.DeviceSnapshot()
|
||||
devices := make(map[string]interface{}, len(snapshot))
|
||||
|
||||
for _, entry := range snapshot {
|
||||
devices[entry.ID] = map[string]interface{}{
|
||||
"info": entry.Device.DeviceInfo,
|
||||
"status": entry.Device.Status(),
|
||||
"lastSeen": entry.Device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,12 +90,13 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Send periodic status updates
|
||||
for id, device := range app.Devices {
|
||||
if device.Status.IsConnected {
|
||||
for _, entry := range app.DeviceSnapshot() {
|
||||
status := entry.Device.Status()
|
||||
if status.IsConnected {
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: id,
|
||||
Data: device.Status,
|
||||
DeviceID: entry.ID,
|
||||
Data: status,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
@@ -134,25 +137,35 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
|
||||
wsClient := conn.Client.NewWebSocketClient(nil)
|
||||
|
||||
// Setup event handlers
|
||||
// Setup event handlers. Each handler funnels its change through
|
||||
// UpdateStatus so concurrent events and the periodic poller
|
||||
// (UpdateDeviceStatus) cannot lose each other's writes.
|
||||
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
|
||||
conn.Status.NowPlaying = &event.NowPlaying
|
||||
conn.Status.LastActivity = time.Now()
|
||||
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
|
||||
s.NowPlaying = &event.NowPlaying
|
||||
s.LastActivity = time.Now()
|
||||
})
|
||||
})
|
||||
|
||||
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
|
||||
conn.Status.Volume = &event.Volume
|
||||
conn.Status.LastActivity = time.Now()
|
||||
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
|
||||
s.Volume = &event.Volume
|
||||
s.LastActivity = time.Now()
|
||||
})
|
||||
})
|
||||
|
||||
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
|
||||
conn.Status.IsConnected = event.ConnectionState.IsConnected()
|
||||
conn.Status.LastActivity = time.Now()
|
||||
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
|
||||
s.IsConnected = event.ConnectionState.IsConnected()
|
||||
s.LastActivity = time.Now()
|
||||
})
|
||||
})
|
||||
|
||||
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
|
||||
conn.Status.Presets = &event.Presets
|
||||
conn.Status.LastActivity = time.Now()
|
||||
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
|
||||
s.Presets = &event.Presets
|
||||
s.LastActivity = time.Now()
|
||||
})
|
||||
})
|
||||
|
||||
// Connect WebSocket
|
||||
@@ -162,64 +175,81 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
}
|
||||
|
||||
conn.WebSocket = wsClient
|
||||
conn.Status.IsConnected = true
|
||||
|
||||
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
|
||||
s.IsConnected = true
|
||||
})
|
||||
|
||||
log.Printf("WebSocket connected for device %s", deviceID)
|
||||
|
||||
// Wait for disconnection
|
||||
wsClient.Wait()
|
||||
|
||||
conn.Status.IsConnected = false
|
||||
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
|
||||
s.IsConnected = false
|
||||
})
|
||||
|
||||
log.Printf("WebSocket disconnected for device %s", deviceID)
|
||||
}
|
||||
|
||||
// UpdateDeviceStatus fetches current status from device
|
||||
// UpdateDeviceStatus fetches current status from the device.
|
||||
//
|
||||
// Network calls run outside the atomic merge so the CAS loop in
|
||||
// UpdateStatus stays fast and doesn't retry slow IO. WebSocket event
|
||||
// handlers running concurrently are not lost: their UpdateStatus
|
||||
// runs against whichever snapshot they observe, and the merge below
|
||||
// sees their changes when it CAS-loops onto the latest status.
|
||||
func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) {
|
||||
// Skip status update if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
statusUpdated := false
|
||||
// Phase 1: slow network fetches. Local vars only, no shared state
|
||||
// is touched yet. Errors are recorded so the merge below can tell
|
||||
// "field N stayed unchanged" apart from "field N got refreshed".
|
||||
nowPlaying, nowPlayingErr := conn.Client.GetNowPlaying()
|
||||
volume, volumeErr := conn.Client.GetVolume()
|
||||
presets, presetsErr := conn.Client.GetPresets()
|
||||
sources, sourcesErr := conn.Client.GetSources()
|
||||
bass, bassErr := conn.Client.GetBass()
|
||||
|
||||
// Get current now playing
|
||||
if nowPlaying, err := conn.Client.GetNowPlaying(); err == nil {
|
||||
conn.Status.NowPlaying = nowPlaying
|
||||
statusUpdated = true
|
||||
}
|
||||
// Phase 2: fast merge. Only fields we successfully fetched
|
||||
// overwrite; everything else keeps the value other goroutines may
|
||||
// have just written.
|
||||
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
|
||||
statusUpdated := false
|
||||
|
||||
// Get current volume
|
||||
if volume, err := conn.Client.GetVolume(); err == nil {
|
||||
conn.Status.Volume = volume
|
||||
statusUpdated = true
|
||||
}
|
||||
if nowPlayingErr == nil {
|
||||
s.NowPlaying = nowPlaying
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get presets
|
||||
if presets, err := conn.Client.GetPresets(); err == nil {
|
||||
conn.Status.Presets = presets
|
||||
statusUpdated = true
|
||||
}
|
||||
if volumeErr == nil {
|
||||
s.Volume = volume
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Update last activity if any status was updated
|
||||
if statusUpdated {
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
// Get sources
|
||||
if sources, err := conn.Client.GetSources(); err == nil {
|
||||
conn.Status.Sources = sources
|
||||
statusUpdated = true
|
||||
}
|
||||
if presetsErr == nil {
|
||||
s.Presets = presets
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get bass (if available)
|
||||
if bass, err := conn.Client.GetBass(); err == nil {
|
||||
conn.Status.Bass = bass
|
||||
statusUpdated = true
|
||||
}
|
||||
if sourcesErr == nil {
|
||||
s.Sources = sources
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Mark as connected if we successfully got at least one status
|
||||
conn.Status.IsConnected = statusUpdated
|
||||
conn.Status.LastActivity = time.Now()
|
||||
if bassErr == nil {
|
||||
s.Bass = bass
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Mark as connected if we successfully got at least one
|
||||
// status from this round. Mirrors prior behaviour.
|
||||
s.IsConnected = statusUpdated
|
||||
s.LastActivity = time.Now()
|
||||
})
|
||||
}
|
||||
|
||||
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
|
||||
@@ -230,7 +260,7 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
device, exists := app.GetDevice(deviceID)
|
||||
if !exists {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -251,7 +281,7 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"status": device.Status(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -293,12 +323,13 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Send device status update
|
||||
status := device.Status()
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"status": status,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -309,13 +340,13 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// If device has active WebSocket connection to SoundTouch device,
|
||||
// also send any real-time updates from that connection
|
||||
if device.WebSocket != nil && device.Status.IsConnected {
|
||||
if device.WebSocket != nil && status.IsConnected {
|
||||
realtimeMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_realtime",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"nowPlaying": device.Status.NowPlaying,
|
||||
"volume": device.Status.Volume,
|
||||
"nowPlaying": status.NowPlaying,
|
||||
"volume": status.Volume,
|
||||
"timestamp": time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
+53
-46
@@ -46,6 +46,11 @@ func main() {
|
||||
Usage: "Network interface name (e.g. eth0) for mDNS and UPnP device discovery. Defaults to the --bind interface name when one was given; leave empty otherwise to auto-pick",
|
||||
EnvVars: []string{"DISCOVERY_INTERFACE"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "devices",
|
||||
Usage: "SoundTouch device IP address(es) to add manually (can be specified multiple times)",
|
||||
EnvVars: []string{"SOUNDTOUCH_DEVICES"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
port := c.String("port")
|
||||
@@ -61,6 +66,7 @@ func main() {
|
||||
}
|
||||
|
||||
rawIface := c.String("interface")
|
||||
manualHosts := c.StringSlice("devices")
|
||||
|
||||
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
|
||||
if rawIface == "" && ifaceName != "" {
|
||||
@@ -97,11 +103,15 @@ func main() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("starting", len(webApp.Devices))
|
||||
webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount())
|
||||
|
||||
for _, host := range manualHosts {
|
||||
addDevice(webApp, host, 8090, "manual")
|
||||
}
|
||||
|
||||
discoverDevices(ctx, webApp, discoveryService)
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("completed", len(webApp.Devices))
|
||||
webApp.BroadcastDiscoveryStatus("completed", webApp.DeviceCount())
|
||||
webApp.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
@@ -200,6 +210,43 @@ func resolveBindAddr(bindAddr string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// addDevice registers a SoundTouch device with the WebApp by fetching
|
||||
// its /info and creating a DeviceConnection. The source label
|
||||
// ("manual" or "discovered") appears in log lines so the operator can
|
||||
// tell apart entries that came from --devices from those found via
|
||||
// mDNS/UPnP. If the host is already known, the existing entry's
|
||||
// LastSeen is bumped and the function returns without re-fetching.
|
||||
func addDevice(app *handlers.WebApp, host string, port int, source string) {
|
||||
// Fast path: skip the network call if we already know this host.
|
||||
if app.TouchDevice(host) {
|
||||
return
|
||||
}
|
||||
|
||||
c := client.NewClient(&client.Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
|
||||
info, err := c.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch device info from %s (%s): %v", host, source, err)
|
||||
return
|
||||
}
|
||||
|
||||
conn := webtypes.NewDeviceConnection(c, info)
|
||||
if !app.AddDevice(host, conn) {
|
||||
// Lost a race — another goroutine inserted the same host
|
||||
// between TouchDevice and AddDevice. AddDevice bumped LastSeen
|
||||
// on the existing entry; discard our conn.
|
||||
return
|
||||
}
|
||||
|
||||
go app.UpdateDeviceStatus(host, conn)
|
||||
|
||||
log.Printf("Added %s device %s (%s) at %s:%d", source, info.Name, info.Type, host, port)
|
||||
}
|
||||
|
||||
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -230,12 +277,12 @@ func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscov
|
||||
defer cancel()
|
||||
|
||||
// Broadcast discovery start
|
||||
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
|
||||
app.BroadcastDiscoveryStatus("starting", app.DeviceCount())
|
||||
|
||||
discoverDevices(ctx, app, discoveryService)
|
||||
|
||||
// Broadcast discovery completion and updated device list
|
||||
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
|
||||
app.BroadcastDiscoveryStatus("completed", app.DeviceCount())
|
||||
app.BroadcastDeviceList()
|
||||
}()
|
||||
})
|
||||
@@ -271,7 +318,7 @@ func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService
|
||||
devices, err := discoveryService.DiscoverDevices(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Discovery failed: %v", err)
|
||||
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
|
||||
app.BroadcastDiscoveryStatus("failed", app.DeviceCount())
|
||||
|
||||
return
|
||||
}
|
||||
@@ -279,46 +326,6 @@ func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService
|
||||
log.Printf("Found %d devices", len(devices))
|
||||
|
||||
for _, device := range devices {
|
||||
deviceID := device.Host // Use host as unique ID for now
|
||||
|
||||
// Skip if we already have this device
|
||||
if _, exists := app.Devices[deviceID]; exists {
|
||||
app.Devices[deviceID].LastSeen = time.Now()
|
||||
continue
|
||||
}
|
||||
|
||||
// Create new device connection
|
||||
clientConfig := &client.Config{
|
||||
Host: device.Host,
|
||||
Port: device.Port,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
soundTouchClient := client.NewClient(clientConfig)
|
||||
|
||||
// Get device info
|
||||
deviceInfo, err := soundTouchClient.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get device info for %s: %v", device.Host, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create device connection
|
||||
conn := &webtypes.DeviceConnection{
|
||||
Client: soundTouchClient,
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{
|
||||
IsConnected: false,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
// Initial status fetch asynchronously to avoid blocking discovery
|
||||
go app.UpdateDeviceStatus(deviceID, conn)
|
||||
|
||||
app.Devices[deviceID] = conn
|
||||
|
||||
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
|
||||
addDevice(app, device.Host, device.Port, "discovered")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
@@ -250,13 +249,9 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
// Add a mock device for testing unknown action validation
|
||||
mockDevice := &webtypes.DeviceConnection{
|
||||
Client: nil,
|
||||
DeviceInfo: &models.DeviceInfo{Name: "Test Device"},
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{IsConnected: true},
|
||||
}
|
||||
app.Devices["testdevice"] = mockDevice
|
||||
mockDevice := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{Name: "Test Device"})
|
||||
mockDevice.SetStatus(&webtypes.DeviceStatus{IsConnected: true})
|
||||
app.AddDevice("testdevice", mockDevice)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// Package webtypes tests for the atomic Status API on DeviceConnection
|
||||
// (Status, SetStatus, UpdateStatus, NewDeviceConnection).
|
||||
package webtypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestNewDeviceConnection_InitialStatus(t *testing.T) {
|
||||
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
|
||||
|
||||
status := conn.Status()
|
||||
if status == nil {
|
||||
t.Fatal("Status() returned nil from a NewDeviceConnection")
|
||||
}
|
||||
|
||||
if status.IsConnected {
|
||||
t.Error("IsConnected should default to false")
|
||||
}
|
||||
|
||||
if status.LastActivity.IsZero() {
|
||||
t.Error("LastActivity should be initialised, got zero time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStatus_ReplacesEntireStatus(t *testing.T) {
|
||||
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
|
||||
conn.SetStatus(&DeviceStatus{
|
||||
Volume: &models.Volume{ActualVolume: 42},
|
||||
IsConnected: true,
|
||||
})
|
||||
|
||||
got := conn.Status()
|
||||
if got.Volume == nil || got.Volume.ActualVolume != 42 {
|
||||
t.Errorf("Volume not stored: got %+v", got.Volume)
|
||||
}
|
||||
|
||||
// Setting a sparser status should wipe previously-set fields.
|
||||
conn.SetStatus(&DeviceStatus{IsConnected: false})
|
||||
|
||||
got = conn.Status()
|
||||
if got.Volume != nil {
|
||||
t.Error("SetStatus did not wipe previously-set Volume")
|
||||
}
|
||||
|
||||
if got.IsConnected {
|
||||
t.Error("SetStatus did not wipe IsConnected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStatus_AppliesMutator(t *testing.T) {
|
||||
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
|
||||
|
||||
conn.UpdateStatus(func(s *DeviceStatus) {
|
||||
s.IsConnected = true
|
||||
s.Volume = &models.Volume{ActualVolume: 30}
|
||||
})
|
||||
|
||||
got := conn.Status()
|
||||
if !got.IsConnected {
|
||||
t.Error("UpdateStatus did not set IsConnected")
|
||||
}
|
||||
|
||||
if got.Volume == nil || got.Volume.ActualVolume != 30 {
|
||||
t.Errorf("UpdateStatus did not set Volume: %+v", got.Volume)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStatus_PreservesUnchangedFields(t *testing.T) {
|
||||
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
|
||||
conn.SetStatus(&DeviceStatus{
|
||||
Volume: &models.Volume{ActualVolume: 10},
|
||||
Bass: &models.Bass{ActualBass: 3},
|
||||
IsConnected: true,
|
||||
})
|
||||
|
||||
// Only touch Volume; Bass and IsConnected must survive.
|
||||
conn.UpdateStatus(func(s *DeviceStatus) {
|
||||
s.Volume = &models.Volume{ActualVolume: 99}
|
||||
})
|
||||
|
||||
got := conn.Status()
|
||||
if got.Volume.ActualVolume != 99 {
|
||||
t.Errorf("Volume = %d, want 99", got.Volume.ActualVolume)
|
||||
}
|
||||
|
||||
if got.Bass == nil || got.Bass.ActualBass != 3 {
|
||||
t.Errorf("Bass not preserved: %+v", got.Bass)
|
||||
}
|
||||
|
||||
if !got.IsConnected {
|
||||
t.Error("IsConnected not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusSnapshotIsolation(t *testing.T) {
|
||||
// A snapshot returned by Status() must NOT change when a later
|
||||
// UpdateStatus replaces a pointer field. This proves the atomic
|
||||
// store gives readers a stable view (so long as the writer
|
||||
// follows the docstring contract of replacing nested pointers).
|
||||
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
|
||||
conn.SetStatus(&DeviceStatus{Volume: &models.Volume{ActualVolume: 1}})
|
||||
|
||||
first := conn.Status()
|
||||
|
||||
conn.UpdateStatus(func(s *DeviceStatus) {
|
||||
s.Volume = &models.Volume{ActualVolume: 2}
|
||||
})
|
||||
|
||||
if first.Volume.ActualVolume != 1 {
|
||||
t.Errorf("Snapshot mutated after later UpdateStatus: got %d, want 1",
|
||||
first.Volume.ActualVolume)
|
||||
}
|
||||
|
||||
if conn.Status().Volume.ActualVolume != 2 {
|
||||
t.Errorf("Current status not updated: got %d, want 2",
|
||||
conn.Status().Volume.ActualVolume)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusConcurrent runs many UpdateStatus writers alongside many
|
||||
// Status() readers. Before atomic.Pointer[DeviceStatus] this pattern
|
||||
// would be flagged by the race detector (writers mutate
|
||||
// conn.Status.X while readers copy conn.Status). With the atomic
|
||||
// pointer it must run clean under `go test -race`.
|
||||
func TestStatusConcurrent(t *testing.T) {
|
||||
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "concurrent"})
|
||||
|
||||
const writers = 16
|
||||
|
||||
const readersPerKind = 16
|
||||
|
||||
const opsPerGoroutine = 200
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(writers + 2*readersPerKind)
|
||||
|
||||
// Writers: each goroutine replaces NowPlaying with a fresh struct
|
||||
// carrying its worker id. Replacement (not in-place mutation)
|
||||
// is what the UpdateStatus contract requires for nested
|
||||
// pointers.
|
||||
for w := 0; w < writers; w++ {
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
|
||||
for i := 0; i < opsPerGoroutine; i++ {
|
||||
conn.UpdateStatus(func(s *DeviceStatus) {
|
||||
s.NowPlaying = &models.NowPlaying{
|
||||
Track: fmt.Sprintf("w%d-%d", worker, i),
|
||||
}
|
||||
s.IsConnected = true
|
||||
})
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
// Readers via Status() — full snapshot.
|
||||
for r := 0; r < readersPerKind; r++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for i := 0; i < opsPerGoroutine; i++ {
|
||||
_ = conn.Status()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Readers that deref a single field. Tests the common
|
||||
// "device.Status().IsConnected" pattern.
|
||||
for r := 0; r < readersPerKind; r++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for i := 0; i < opsPerGoroutine; i++ {
|
||||
_ = conn.Status().IsConnected
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// After all writers finish, IsConnected should be true (every
|
||||
// writer sets it). The exact NowPlaying value is whichever
|
||||
// writer landed last, but it must be a valid non-nil pointer.
|
||||
final := conn.Status()
|
||||
if !final.IsConnected {
|
||||
t.Error("IsConnected should be true after writers ran")
|
||||
}
|
||||
|
||||
if final.NowPlaying == nil {
|
||||
t.Error("NowPlaying should be non-nil after writers ran")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
package webtypes
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
@@ -29,13 +30,21 @@ type SoundTouchClient interface {
|
||||
NewWebSocketClient(config interface{}) *client.WebSocketClient
|
||||
}
|
||||
|
||||
// DeviceConnection wraps a SoundTouch client with WebSocket connection
|
||||
// DeviceConnection wraps a SoundTouch client with WebSocket connection.
|
||||
//
|
||||
// The Status field is stored behind atomic.Pointer so concurrent
|
||||
// readers (HTTP handlers, WebSocket broadcasters) never observe a
|
||||
// torn struct while a writer (UpdateDeviceStatus, WebSocket event
|
||||
// handlers) is mid-update. Access status through Status / SetStatus
|
||||
// / UpdateStatus rather than the private field; construct connections
|
||||
// via NewDeviceConnection to guarantee the status is initialised.
|
||||
type DeviceConnection struct {
|
||||
Client *client.Client
|
||||
WebSocket *client.WebSocketClient
|
||||
DeviceInfo *models.DeviceInfo
|
||||
LastSeen time.Time
|
||||
Status DeviceStatus
|
||||
|
||||
status atomic.Pointer[DeviceStatus]
|
||||
}
|
||||
|
||||
// DeviceStatus represents the current device state
|
||||
@@ -49,6 +58,64 @@ type DeviceStatus struct {
|
||||
LastActivity time.Time `json:"lastActivity"`
|
||||
}
|
||||
|
||||
// NewDeviceConnection creates a fully-initialised connection. The
|
||||
// status starts with IsConnected=false and LastActivity set to now;
|
||||
// real values arrive via UpdateStatus once the device responds.
|
||||
func NewDeviceConnection(c *client.Client, info *models.DeviceInfo) *DeviceConnection {
|
||||
conn := &DeviceConnection{
|
||||
Client: c,
|
||||
DeviceInfo: info,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
conn.status.Store(&DeviceStatus{
|
||||
IsConnected: false,
|
||||
LastActivity: time.Now(),
|
||||
})
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
// Status returns a snapshot of the current device status. The returned
|
||||
// pointer is read-only from the caller's perspective; mutating the
|
||||
// pointed-to struct has no effect on the stored status. Use
|
||||
// UpdateStatus or SetStatus to apply changes. Never returns nil for
|
||||
// connections built via NewDeviceConnection.
|
||||
func (c *DeviceConnection) Status() *DeviceStatus {
|
||||
return c.status.Load()
|
||||
}
|
||||
|
||||
// SetStatus atomically replaces the entire status. Use sparingly —
|
||||
// UpdateStatus is the preferred entry point because it preserves
|
||||
// concurrent changes from other goroutines.
|
||||
func (c *DeviceConnection) SetStatus(s *DeviceStatus) {
|
||||
c.status.Store(s)
|
||||
}
|
||||
|
||||
// UpdateStatus atomically applies mut to a copy of the current status
|
||||
// and stores the result. If another goroutine updates the status while
|
||||
// mut runs, UpdateStatus retries with the newer status — so concurrent
|
||||
// writers cannot silently lose each other's changes.
|
||||
//
|
||||
// The copy mut receives is a shallow value copy of the previous status.
|
||||
// Nested pointer fields (NowPlaying, Volume, Presets, Sources, Bass)
|
||||
// share their backing struct with the previous version: callers MUST
|
||||
// REPLACE these pointers (s.Volume = &models.Volume{...}) rather than
|
||||
// mutate through them (s.Volume.ActualVolume++ would race with any
|
||||
// reader still holding the previous snapshot). Production callers
|
||||
// receive these values fresh from the device API, so this is the
|
||||
// natural shape.
|
||||
func (c *DeviceConnection) UpdateStatus(mut func(*DeviceStatus)) {
|
||||
for {
|
||||
old := c.status.Load()
|
||||
next := *old
|
||||
mut(&next)
|
||||
|
||||
if c.status.CompareAndSwap(old, &next) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// APIResponse is a standard JSON response wrapper
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
|
||||
@@ -145,31 +145,30 @@ func TestDeviceConnection(t *testing.T) {
|
||||
MuteEnabled: false,
|
||||
}
|
||||
|
||||
conn := &DeviceConnection{
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: DeviceStatus{
|
||||
NowPlaying: nowPlaying,
|
||||
Volume: volume,
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
conn := NewDeviceConnection(nil, deviceInfo)
|
||||
conn.SetStatus(&DeviceStatus{
|
||||
NowPlaying: nowPlaying,
|
||||
Volume: volume,
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
})
|
||||
|
||||
t.Run("device connection fields", func(t *testing.T) {
|
||||
if conn.DeviceInfo.Name != "Test Speaker" {
|
||||
t.Errorf("Expected device name 'Test Speaker', got '%s'", conn.DeviceInfo.Name)
|
||||
}
|
||||
|
||||
if conn.Status.NowPlaying.Track != "Test Track" {
|
||||
t.Errorf("Expected track 'Test Track', got '%s'", conn.Status.NowPlaying.Track)
|
||||
status := conn.Status()
|
||||
|
||||
if status.NowPlaying.Track != "Test Track" {
|
||||
t.Errorf("Expected track 'Test Track', got '%s'", status.NowPlaying.Track)
|
||||
}
|
||||
|
||||
if conn.Status.Volume.ActualVolume != 50 {
|
||||
t.Errorf("Expected volume 50, got %d", conn.Status.Volume.ActualVolume)
|
||||
if status.Volume.ActualVolume != 50 {
|
||||
t.Errorf("Expected volume 50, got %d", status.Volume.ActualVolume)
|
||||
}
|
||||
|
||||
if !conn.Status.IsConnected {
|
||||
if !status.IsConnected {
|
||||
t.Error("Expected device to be connected")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -355,9 +355,11 @@ func handlePreset(event *models.PresetUpdatedEvent, verbose bool) {
|
||||
for _, preset := range presets.Preset {
|
||||
fmt.Printf(" 📻 Preset %d:", preset.ID)
|
||||
|
||||
if preset.ContentItem != nil {
|
||||
fmt.Printf(" %s", preset.ContentItem.ItemName)
|
||||
fmt.Printf(" (%s)", preset.ContentItem.Source)
|
||||
// IsEmpty catches both <preset/> and INVALID_SOURCE
|
||||
// placeholders; using the nil-safe helpers below means the
|
||||
// inner Printf never dereferences a nil ContentItem.
|
||||
if !preset.IsEmpty() {
|
||||
fmt.Printf(" %s (%s)", preset.GetDisplayName(), preset.GetSource())
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
accounts/
|
||||
backend/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
|
||||
@@ -105,3 +105,4 @@
|
||||
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
* [Stockholm Port Guide](stockholm-port-guide.md)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{%- comment -%}
|
||||
Render Mermaid diagrams in docs pages.
|
||||
|
||||
Markdown ```mermaid fenced blocks are emitted by Kramdown as
|
||||
<pre><code class="language-mermaid">…</code></pre>, but Mermaid only
|
||||
auto-renders elements with class="mermaid". This snippet rewrites the
|
||||
pre/code nodes into div.mermaid before initialising the library.
|
||||
|
||||
Loaded as an ES module from the jsDelivr CDN so we don't have to vendor
|
||||
the library into the repo. Pinned to a major version for cache stability.
|
||||
{%- endcomment -%}
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
||||
|
||||
document.querySelectorAll('pre > code.language-mermaid').forEach((code) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'mermaid';
|
||||
div.textContent = code.textContent;
|
||||
code.parentElement.replaceWith(div);
|
||||
});
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });
|
||||
mermaid.run();
|
||||
</script>
|
||||
@@ -1,5 +1,10 @@
|
||||
# Spotify OAuth Integration
|
||||
|
||||
> **New here?** Start with [spotify-overview.md](spotify-overview.md) for the
|
||||
> mental model (Spotify Connect vs OAuth-intercept, DNS rewrite gotcha,
|
||||
> end-to-end token lifecycle). This document zooms in on the OAuth flows and
|
||||
> management endpoints.
|
||||
|
||||
The SoundTouch service supports Spotify OAuth integration to broker access tokens for SoundTouch speakers. This is particularly useful for maintaining Spotify Connect functionality after the Bose cloud shutdown (scheduled for May 2026).
|
||||
|
||||
## OAuth Flows
|
||||
@@ -91,43 +96,23 @@ sequenceDiagram
|
||||
Note over Speaker: Speaker now has Spotify access
|
||||
```
|
||||
|
||||
## Boot Primer Script
|
||||
## Priming Speakers
|
||||
|
||||
A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../../scripts/spotify/spotify-boot-primer.sh).
|
||||
|
||||
This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../../scripts/spotify/README.md) and [INSTALL.md](../../scripts/spotify/INSTALL.md) for instructions.
|
||||
|
||||
### Automated Installation via Service
|
||||
|
||||
The SoundTouch service provides a dedicated management endpoint to automatically handle the installation of the Spotify boot primer on the speaker:
|
||||
`POST /mgmt/devices/{deviceId}/spotify/install-primer`
|
||||
|
||||
### Automated Installation Steps
|
||||
When you run the Spotify primer installation, the service performs the following:
|
||||
1. **Directories**: Creates `/mnt/nv/bin` and `/mnt/nv/BoseApp-Persistence/1` on the speaker.
|
||||
2. **Binary**: Uploads the `spotify-boot-primer` script to the speaker.
|
||||
3. **Configuration**: Automatically generates and uploads `spotify-primer.conf` containing the service's URL and management credentials.
|
||||
4. **Boot Hook**: Injects a call to the primer in the speaker's `/mnt/nv/rc.local` using idempotent markers.
|
||||
5. **Environment**: Updates `/mnt/nv/.profile` to include `/mnt/nv/bin` in the `PATH` for easier manual troubleshooting via SSH.
|
||||
|
||||
- **Idempotent Patching**: The service uses explicit markers to inject the hook, ensuring it doesn't corrupt existing content.
|
||||
- **Coexistence**: The service-injected hook is designed to coexist with a manually installed `rc.local` (e.g., from the community gist). It only adds a call to `/mnt/nv/bin/spotify-boot-primer` if it's not already managed by a service-controlled block.
|
||||
- **Markers**: Look for the following markers in your speaker's `/mnt/nv/rc.local`:
|
||||
- `# --- Aftertouch Spotify hook START ---`
|
||||
- `# --- Aftertouch Spotify hook END ---`
|
||||
- **Cleanup**: Reverting a migration via the service will cleanly remove these marker-delimited blocks.
|
||||
> **Note:** The on-device boot-primer flow (installing `spotify-boot-primer.sh` onto the speaker's `/mnt/nv` and hooking it from `rc.local`) is **deprecated**. AfterTouch now uses a server-centric model: the service registers a `SPOTIFY` source in marge for the device's paired account and pushes credentials via ZeroConf from the server side, triggered on `power_on` and a manual "Prime" action. See [spotify-priming-strategy.md](spotify-priming-strategy.md) for the current model and rationale.
|
||||
>
|
||||
> The artifacts under `scripts/spotify/` are kept as historical reference for users who still rely on the on-device approach. There is no longer a `/mgmt/devices/{deviceId}/spotify/install-primer` endpoint.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|---------------------------------------------------|-------|-----------------------------------------------------------------------|
|
||||
| POST | `/mgmt/devices/{deviceId}/spotify/install-primer` | Basic | Install Spotify boot primer on speaker (deviceId or IP) |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/init` | Basic | Start OAuth flow, returns authorization URL |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/confirm` | Basic | Mobile app confirm (ueberboese deep link delivers code, returns JSON) |
|
||||
| GET | `/mgmt/spotify/accounts` | Basic | List linked Spotify accounts (tokens stripped) |
|
||||
| GET | `/mgmt/spotify/token` | Basic | Get fresh access token (auto-refreshes if expired) |
|
||||
| POST | `/mgmt/spotify/entity` | Basic | Resolve Spotify URI to name + image URL |
|
||||
| POST | `/mgmt/spotify/prime` | Basic | Manually trigger server-side priming of a discovered speaker |
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Spotify on SoundTouch — Overview
|
||||
|
||||
This is the entry point for understanding how Spotify works on a SoundTouch
|
||||
speaker behind AfterTouch. Read this first; the deeper docs assume you already
|
||||
have the mental model below.
|
||||
|
||||
> **Premium likely required.** As far as we know, Spotify Connect on
|
||||
> SoundTouch only works with a Spotify Premium account — this matches our
|
||||
> testing and matches what other SoundTouch-replacement projects report, but
|
||||
> we have not exhaustively verified every account tier or region. None of the
|
||||
> workarounds in this document change Spotify's account-tier requirements.
|
||||
|
||||
## Two completely separate Spotify paths
|
||||
|
||||
These are routinely confused. They share a speaker and a Spotify account, but
|
||||
they ride on different infrastructure and fail for different reasons.
|
||||
|
||||
### 1. Spotify Connect (speaker-native, independent of AfterTouch)
|
||||
|
||||
- The speaker advertises itself on the LAN as a Spotify Connect endpoint
|
||||
(mDNS service `_spotify-connect._tcp`).
|
||||
- You open the Spotify app on your phone or desktop, tap the Connect device
|
||||
picker, and select the SoundTouch.
|
||||
- Audio streams directly from Spotify's CDN to the speaker. Token handling,
|
||||
session setup, and playback all happen between Spotify and the speaker.
|
||||
- **AfterTouch is not involved.** It still works even if AfterTouch is
|
||||
offline.
|
||||
|
||||
This is the simplest path. If you only want to push playback from your phone,
|
||||
you do not need to link Spotify to AfterTouch at all — see [Manual kick-start
|
||||
alternative](#manual-kick-start-alternative) below.
|
||||
|
||||
### 2. OAuth-intercept path (managed by AfterTouch)
|
||||
|
||||
This is what enables features that originate **from the speaker**:
|
||||
|
||||
- Spotify presets on the speaker's buttons.
|
||||
- Spotify playback from the Bose app's source picker.
|
||||
- "Resume Spotify" after a power cycle without touching the Spotify app.
|
||||
|
||||
After Bose's cloud shutdown (May 2026), the speaker can no longer reach
|
||||
Bose's OAuth server for Spotify token refresh. AfterTouch intercepts those
|
||||
calls via DNS, brokers tokens with Spotify using your linked account, and
|
||||
hands them back to the speaker.
|
||||
|
||||
The rest of this document describes that path.
|
||||
|
||||
## Setup at a glance
|
||||
|
||||
Full step-by-step is in
|
||||
[docs/guides/MUSIC-SERVICES.md](../guides/MUSIC-SERVICES.md). Summary:
|
||||
|
||||
1. **Register a Spotify developer app** (one-time, by the AfterTouch operator).
|
||||
2. **Configure AfterTouch** with the Client ID, Client Secret, and Redirect
|
||||
URI in the Settings tab.
|
||||
3. **Authorize your Spotify account** via the Local Account tab — completes
|
||||
the OAuth flow and persists a long-lived refresh token to AfterTouch's
|
||||
datastore.
|
||||
4. **Prime each speaker** so its source list and ZeroConf state know about
|
||||
Spotify.
|
||||
|
||||
After step 4, presets and Bose-app-initiated Spotify playback work.
|
||||
|
||||
## The DNS rewrite — easy to miss, breaks everything
|
||||
|
||||
Bose firmware does **not** read a separate OAuth server hostname from
|
||||
configuration. It derives the OAuth host from the marge host by inserting
|
||||
`oauth` into the first label:
|
||||
|
||||
| Purpose | Hostname |
|
||||
|-----------------|---------------------------|
|
||||
| Marge / sources | `streaming.bose.com` |
|
||||
| OAuth refresh | `streamingoauth.bose.com` |
|
||||
|
||||
**Both hostnames must resolve to AfterTouch.** AfterTouch's DNS server hijacks
|
||||
both, but if you bypass that DNS server (e.g. by hard-coding only the marge
|
||||
hostname in `/etc/hosts`, or by routing only one through a custom resolver),
|
||||
token refresh will silently die while the speaker still pulls sources.
|
||||
Symptom: the speaker briefly streams Spotify after priming, then stops at the
|
||||
first token refresh ~1 hour later.
|
||||
|
||||
If you self-host AfterTouch at e.g. `aftertouch.local`, you would equivalently
|
||||
need `aftertouchoauth.local` for the OAuth interception path.
|
||||
|
||||
## End-to-end token lifecycle
|
||||
|
||||
What actually happens, from priming to steady-state playback:
|
||||
|
||||
1. **Operator links Spotify account.** OAuth flow stores
|
||||
`{user_id, refresh_token, bose_secret}` in `spotify/accounts.json`. The
|
||||
`bose_secret` is an opaque surrogate (e.g. `bs-deadbeef…`) that AfterTouch
|
||||
issues; the speaker only ever sees this surrogate, never the real Spotify
|
||||
refresh token.
|
||||
2. **Priming runs.** Either on speaker `power_on`, on discovery, or on a
|
||||
manual `POST /mgmt/spotify/prime`. AfterTouch:
|
||||
- Resolves the speaker's currently-paired account via live `:8090/info`
|
||||
(`margeAccountUUID`).
|
||||
- Writes a `SPOTIFY` `ConfiguredSource` into marge under that account with
|
||||
`secret = bose_secret`, `secretType = token_version_3`.
|
||||
- POSTs `<updates><sourcesUpdated/></updates>` to the speaker's
|
||||
`:8090/notification`, causing the speaker to re-fetch
|
||||
`/streaming/account/{account}/full` and pick up the new source.
|
||||
- Optionally pushes a fresh access token to the speaker's ZeroConf
|
||||
endpoint (`:8200/zc?action=addUser`). This is best-effort — see
|
||||
[ZeroConf clientId and benign 404s](#zeroconf-clientid-and-benign-404s).
|
||||
3. **Speaker pulls sources.** It now has a SPOTIFY entry with the surrogate
|
||||
as its credential. The speaker stores this; from its perspective the
|
||||
surrogate is the refresh token.
|
||||
4. **Speaker uses Spotify.** When it needs a fresh access token (every ~1 h
|
||||
on Spotify's clock), it POSTs to
|
||||
`streamingoauth.bose.com/oauth/device/{deviceID}/music/musicprovider/15/token/cs3`
|
||||
with the surrogate.
|
||||
5. **AfterTouch translates.** DNS hijack routes the request to AfterTouch,
|
||||
which looks up the surrogate, performs the real refresh against Spotify
|
||||
using the stored refresh token, and returns the resulting access token to
|
||||
the speaker.
|
||||
6. **Speaker uses the access token** for Spotify Web API metadata calls
|
||||
(artwork, track lookups, playback container resolution).
|
||||
|
||||
Forensic details of the request shapes are in
|
||||
[docs/reference/spotify-account-addition.md](../reference/spotify-account-addition.md).
|
||||
The cryptographic specifics of the ZeroConf `addUser` blob are in
|
||||
[spotify-priming-strategy.md](spotify-priming-strategy.md).
|
||||
|
||||
## ZeroConf clientId and benign 404s
|
||||
|
||||
`GET http://<speaker>:8200/zc?action=getInfo` returns, among other fields:
|
||||
|
||||
```json
|
||||
"clientID": "79ebcb219e8e4e9a892e796607931810"
|
||||
"tokenType": "accesstoken"
|
||||
"activeUser": "<spotify-user-id-or-empty>"
|
||||
```
|
||||
|
||||
That `clientID` is **Bose's official Spotify Connect partner client_id**,
|
||||
baked into firmware. It is **not** the client_id of the developer app you
|
||||
registered for AfterTouch — those are two unrelated OAuth apps, by design.
|
||||
The Bose-baked one is what Spotify Connect uses when a Spotify mobile app
|
||||
discovers the speaker on the LAN. The AfterTouch-registered one is what
|
||||
brokers refresh tokens for the OAuth-intercept path. They never converge.
|
||||
|
||||
**Implication:** an access token AfterTouch obtained under its own client_id
|
||||
is not directly usable as a Spotify Connect session token. Pushing it via
|
||||
ZeroConf `addUser` is best-effort, and the speaker may respond with a `404`
|
||||
and an empty body when its `activeUser` already matches the username being
|
||||
pushed — that is the firmware's idiomatic "no transition required" signal,
|
||||
not a failure. AfterTouch recognises this case (`zeroconf.ErrAddUserNoOp`)
|
||||
and logs it as an expected no-op rather than an error.
|
||||
|
||||
A 404 **with a body**, or any other non-2xx, is treated as a real failure
|
||||
and logged loudly with the response headers and body so it can be
|
||||
diagnosed.
|
||||
|
||||
## Manual kick-start alternative
|
||||
|
||||
You can skip the OAuth setup entirely if you only want playback pushed from
|
||||
the Spotify app:
|
||||
|
||||
1. Open the Spotify mobile/desktop app.
|
||||
2. Start any track.
|
||||
3. Open the Connect device picker, select the SoundTouch.
|
||||
|
||||
The speaker now holds an in-memory Spotify Connect session and can play
|
||||
until next reboot. Presets and Bose-app-initiated Spotify playback will
|
||||
still not work — those require the OAuth-intercept path — but Spotify-app-
|
||||
initiated playback does.
|
||||
|
||||
## Troubleshooting quick reference
|
||||
|
||||
| Symptom | Most likely cause |
|
||||
|----------------------------------------------------|------------------------------------------------------------------------------------------------|
|
||||
| Preset stores then fails: "invalid SourceID" | No `SPOTIFY` source in marge for the speaker's paired account. Re-run priming. |
|
||||
| Preset stores fine; playback dies after ~1 hour | `streamingoauth.bose.com` not pointed at AfterTouch (DNS rewrite gap). |
|
||||
| Speaker has source but `Sources.xml` looks stale | `<sourcesUpdated/>` notification did not reach the speaker. Re-run priming or POST it by hand. |
|
||||
| ZeroConf `addUser` returns 404, empty body | Benign no-op; speaker already has `activeUser` set. Marge path is authoritative. |
|
||||
| Spotify Connect device picker doesn't show speaker | Unrelated to AfterTouch; check the speaker's mDNS visibility on the LAN. |
|
||||
|
||||
## Where to go next
|
||||
|
||||
- **Setup walkthrough:** [docs/guides/MUSIC-SERVICES.md](../guides/MUSIC-SERVICES.md)
|
||||
- **OAuth flow details (browser + mobile + endpoint table):** [spotify-oauth.md](spotify-oauth.md)
|
||||
- **Priming strategy, ZeroConf DH protocol, deployment topologies:** [spotify-priming-strategy.md](spotify-priming-strategy.md)
|
||||
- **Forensic request/response analysis from the Stockholm app:** [docs/reference/spotify-account-addition.md](../reference/spotify-account-addition.md)
|
||||
@@ -1,5 +1,9 @@
|
||||
# Spotify Priming Strategy
|
||||
|
||||
> **New here?** Start with [spotify-overview.md](spotify-overview.md) for the
|
||||
> mental model. This document goes deep on the priming protocol, ZeroConf DH
|
||||
> exchange, and deployment topologies.
|
||||
|
||||
This document outlines the strategy for ensuring Bose SoundTouch devices are correctly "primed" for Spotify Connect integration within the AfterTouch ecosystem.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -86,6 +86,7 @@ A factory reset wipes Wi-Fi credentials, account pairing, and all presets, retur
|
||||
| SoundTouch 10 | Power on; hold **Preset 1** + **Volume −** for 10 s | Wi-Fi indicator glows solid amber |
|
||||
| SoundTouch 20 | Power on; hold **Preset 1** + **Volume −** for 10 s | Lights blink L→R, then solid amber |
|
||||
| SoundTouch 20 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
|
||||
| SoundTouch 30 | Power on; hold **Preset 1** + **Volume −** for 10 s (display counts down 10–1) | Display shows "Hold to restore factory settings", then restarts |
|
||||
| SoundTouch 30 Series III | Hold **Preset 1** + **Preset 6** simultaneously for ~10 s | White LED sweep |
|
||||
| SoundTouch 300 | Hold **Volume −** until light bar blinks rapidly (~15 s) | Rapid blink → off → on |
|
||||
| SoundTouch 10 (alt) | Press and hold the back recessed **Reset** pinhole for 10 s | Status LED restarts |
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
This guide explains how to link your Spotify or Amazon Music account to AfterTouch so your speakers can stream music from those services.
|
||||
|
||||
> For Spotify, a higher-level mental model of how the integration works —
|
||||
> Spotify Connect vs. AfterTouch's OAuth-intercept path, the
|
||||
> `streamingoauth.bose.com` DNS gotcha, and the token lifecycle — is in
|
||||
> [docs/concepts/spotify-overview.md](../concepts/spotify-overview.md).
|
||||
> Read that if priming or playback isn't behaving as you'd expect.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -154,26 +154,30 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
|
||||
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
|
||||
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
@@ -188,6 +192,57 @@ SERVER_URL=https://my-soundtouch.example.com soundtouch-service --port 443
|
||||
LOG_PROXY_BODY=true REDACT_PROXY_LOGS=false soundtouch-service
|
||||
```
|
||||
|
||||
## Stockholm Frontend
|
||||
|
||||
The Stockholm frontend is the patched Bose SoundTouch app UI served directly by the service. When enabled, opening `http://<server>:8000` in a browser shows the full app interface, which communicates with your speakers via the local service instead of Bose's cloud.
|
||||
|
||||
### Getting the Stockholm files
|
||||
|
||||
The Stockholm UI files are not bundled in this repository — you supply them from [krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app). See that project's README for how to obtain the `stockholm.zip`. Once you have it:
|
||||
|
||||
```bash
|
||||
# 1. Place stockholm.zip in stockholm_zip/
|
||||
mkdir -p stockholm_zip
|
||||
cp /path/to/stockholm.zip stockholm_zip/
|
||||
|
||||
# 2. Build the Docker image that applies the patches
|
||||
make build-stockholm-image
|
||||
|
||||
# 3. Extract and patch the frontend into ./stockholm/
|
||||
make prepare-stockholm
|
||||
```
|
||||
|
||||
The `./stockholm/` directory is now ready to use.
|
||||
|
||||
### Enabling the Stockholm UI
|
||||
|
||||
Pass the directory to the service at startup:
|
||||
|
||||
```bash
|
||||
# Development (recommended): builds the service and runs it with
|
||||
# Stockholm enabled, checking that prepare-stockholm has run.
|
||||
make dev-service-stockholm
|
||||
|
||||
# Binary
|
||||
soundtouch-service --stockholm-dir ./stockholm
|
||||
|
||||
# Environment variable
|
||||
STOCKHOLM_DIR=./stockholm soundtouch-service
|
||||
|
||||
# Docker Compose — add to the environment section of docker-compose.yml
|
||||
# STOCKHOLM_DIR=/app/stockholm
|
||||
# and mount the stockholm/ directory into the container
|
||||
```
|
||||
|
||||
### Stockholm environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `STOCKHOLM_DIR` | Path to the extracted Stockholm frontend (enables the UI) |
|
||||
| `MARGE_URL` | Override the streaming/marge URL written into `config.json`. Defaults to `SERVER_URL`. Only set this to `SERVER_URL/marge` when routing through a soundcork backend. |
|
||||
| `MARGE_AUTH_TOKEN` | Pre-seed the session auth token so the first app launch skips the login screen |
|
||||
| `MARGE_ACCOUNT_ID` | Pre-seed the account ID — device discovery will only show speakers on this account |
|
||||
|
||||
## Device Migration
|
||||
|
||||
### Understanding Migration
|
||||
|
||||
@@ -105,6 +105,54 @@ iperf3 -c 192.168.1.1 # If iperf server available
|
||||
|
||||
## 🌐 **Connection Issues**
|
||||
|
||||
### ❌ Every cloud source shows `status="UNAVAILABLE"` / can't stream anything
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- The speaker's `/sources` (or the soundtouch-cli `source availability` output) lists every cloud-backed source — Spotify, TuneIn, Internet Radio, AirPlay, Amazon, Alexa — as `status="UNAVAILABLE"`.
|
||||
- Often only AUX shows `status="READY"`.
|
||||
- The speaker can be reached on the LAN (`:8090/info` works) but no Internet streaming source can be selected.
|
||||
|
||||
This is a different failure mode from the [`Curl 7` case below](#-speaker-logs-curl-7-http-0-and-aftertouch-sees-no-http-requests): the speaker can reach AfterTouch but doesn't have the account state to authenticate any cloud surface, so every cloud handler 401s itself out.
|
||||
|
||||
**Three-step diagnostic checklist** (in order — the cause is almost always one of these):
|
||||
|
||||
#### 1. Is `:443` reachable on AfterTouch?
|
||||
|
||||
The AfterTouch Settings tab now ships a preflight that flips ✅ / ❌ for whether the speaker can open a TLS handshake to AfterTouch's HTTPS listener. If `:443` is ❌, follow the steps in [HTTPS-SETUP.md → Binding to port 443](HTTPS-SETUP.md#binding-to-port-443).
|
||||
|
||||
A failing preflight at this layer typically presents as `Curl 7, http 0` in the speaker's syslog (see the [`Curl 7` entry below](#-speaker-logs-curl-7-http-0-and-aftertouch-sees-no-http-requests) for the focused walkthrough).
|
||||
|
||||
#### 2. Does the speaker have a `margeAccountUUID`?
|
||||
|
||||
```bash
|
||||
curl -s http://<speaker-ip>:8090/info | xmllint --xpath '/info/margeAccountUUID/text()' -
|
||||
```
|
||||
|
||||
If the element is empty (or you get no output), the speaker has no account token — every cloud surface that requires authentication will 401 itself out. The Migration tab in AfterTouch detects this and renders:
|
||||
|
||||
> **Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply**
|
||||
|
||||
The Devices list also shows a `⚠ Not paired — re-pair` badge. To resolve, **open the Migration tab**, pick a previous account ID from the dropdown (or click **Generate**), and click **Apply** — same flow as the [factory-reset recovery](#-presets-flash-then-revert-to-select-a-preset-after-a-factory-reset) section below.
|
||||
|
||||
#### 3. What does `logread` say while you trigger a failing source?
|
||||
|
||||
SSH into the speaker (see [DEVICE-LOGGING.md](../DEVICE-LOGGING.md#1-accessing-system-logs-requires-root)) and capture:
|
||||
|
||||
```bash
|
||||
logread -f | grep -v '127.0.0.1:'
|
||||
```
|
||||
|
||||
…while you select a failing source in the SoundTouch app or via `soundtouch-cli`. The lines around the failed attempt usually name the failing host + protocol — TLS handshake error, token fetch 401, missing route, etc. — and that's enough to file an actionable issue.
|
||||
|
||||
**Common outcomes:**
|
||||
|
||||
- ❌ `:443` → fix HTTPS routing, sources transition to READY on the next refresh.
|
||||
- ❌ `margeAccountUUID` empty → run Migration → Apply, sources reappear after `<sourcesUpdated/>` triggers a `/sources` re-sync.
|
||||
- Everything looks right but sources still UNAVAILABLE → the `logread` snippet is the next signal; open an issue with it attached.
|
||||
|
||||
> **Note on the firmware-internal placeholder sources.** The `<sourceItem source="SPOTIFY" sourceAccount="SpotifyConnectUserName" ...>`, `SpotifyAlexaUserName`, `UPNP/UPnPUserName`, `STORED_MUSIC_MEDIA_RENDERER/StoredMusicUserName`, and `QPLAY/QPlay{1,2}UserName` entries that appear in `/sources` even on a broken or unpaired speaker are *firmware-synthesized*. They show up regardless of AfterTouch's source list — their `status="UNAVAILABLE"` does not indicate an AfterTouch problem. Use the three checks above to diagnose the actual cause.
|
||||
|
||||
### ❌ Speaker logs `Curl 7, http 0` and AfterTouch sees no HTTP requests
|
||||
|
||||
**Symptoms:**
|
||||
@@ -335,6 +383,87 @@ client.SelectAux()
|
||||
|
||||
---
|
||||
|
||||
## 🎶 **Music Service & Preset Issues**
|
||||
|
||||
### ❌ Spotify preset fails with "Current content cannot be saved as preset"
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
You push playback to the speaker via Spotify Connect from the Spotify mobile/desktop app. Audio plays fine. You try to store it as a preset and the CLI reports:
|
||||
|
||||
```
|
||||
$ soundtouch-cli preset store-current --slot 2
|
||||
Storing current content as preset 2 from 192.168.x.y:8090...
|
||||
✗ Current content cannot be saved as preset
|
||||
Content: <track name>
|
||||
Source: SPOTIFY
|
||||
2026/05/16 09:13:10 current content cannot be preset
|
||||
```
|
||||
|
||||
…and `soundtouch-cli play now` shows `Source Account: SpotifyConnectUserName`.
|
||||
|
||||
**Cause:**
|
||||
|
||||
The speaker firmware marks Spotify-Connect-pushed content as **non-presetable** at the NowPlaying layer:
|
||||
|
||||
```xml
|
||||
<ContentItem source="SPOTIFY" type="DO_NOT_RESUME" ...
|
||||
sourceAccount="SpotifyConnectUserName" isPresetable="false">
|
||||
```
|
||||
|
||||
That `isPresetable="false"` means the firmware can't independently re-fetch the stream later — it only knows about the session token your phone pushed via the Spotify Connect protocol, which is ephemeral. The speaker refuses the preset *locally*, before any storePreset request reaches AfterTouch's marge.
|
||||
|
||||
**Why an OAuth-linked Spotify account changes the answer:**
|
||||
|
||||
When AfterTouch has a Spotify OAuth account linked (see [MUSIC-SERVICES.md](MUSIC-SERVICES.md)), the speaker has a *persistent* Spotify source it can use to resolve the content URI later — typically an album/playlist container. With that source available, the firmware rewrites the content item from `DO_NOT_RESUME` to `tracklisturl` at save time, flips `isPresetable` to `true`, and the preset goes through. The recall path then routes through AfterTouch's `/oauth/.../cs3` token broker, which returns a Spotify access token for your linked account.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. Set up Spotify OAuth in AfterTouch following [MUSIC-SERVICES.md](MUSIC-SERVICES.md). The high-level model (Spotify Connect vs the OAuth-intercept path, the `streamingoauth.bose.com` DNS rewrite, the token lifecycle) is in [spotify-overview.md](../concepts/spotify-overview.md).
|
||||
2. Make sure you're on **v0.84.0 or later** — earlier versions had a custom-OAuth-client bug that caused playback to hang at "Buffering".
|
||||
3. Re-prime the speaker (Migration tab → **Prime Spotify**, or wait for the watchdog), then retry the preset save with Connect-pushed playback.
|
||||
|
||||
**What this won't fix:**
|
||||
|
||||
A Connect-only setup with no OAuth account linked in AfterTouch — that's a firmware-level constraint we can't route around from the server side. The speaker simply doesn't have credentials it can use to replay the content later, so it refuses to preset.
|
||||
|
||||
### ❌ TuneIn (or Internet Radio) missing from `/sources` after a factory reset
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- The speaker is happily migrated and reachable; most cloud sources work.
|
||||
- `curl http://<speaker-ip>:8090/sources` lists AUX, Bluetooth, Spotify Connect placeholders, etc. — but **no `TUNEIN` entry**.
|
||||
- `soundtouch-cli source content --source TUNEIN --type stationurl --location /v1/playback/station/<id> --name '<name>'` fails with `1005` (or playing a TuneIn preset silently does nothing).
|
||||
- Other devices on the same setup have `TUNEIN` in `/sources` and work fine.
|
||||
|
||||
**Cause:**
|
||||
|
||||
TuneIn is **not a default source** on a freshly factory-reset SoundTouch. The speaker only adds `TUNEIN` to its `Sources.xml` after the source has been played at least once. Until then, source-selection requests for `TUNEIN` are rejected as invalid.
|
||||
|
||||
This is firmware behaviour — independent of AfterTouch — and is why one device can have `TUNEIN` and a sibling device (just reset) can be missing it. The same applies to `LOCAL_INTERNET_RADIO` if the speaker was reset before any LIR content was played.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Play any TuneIn station once to register the source. Two equivalent paths:
|
||||
|
||||
1. **Via the SoundTouch app** — open the app, pick TuneIn, play any station. The source appears in `/sources` after a few seconds.
|
||||
2. **Via `soundtouch-cli`** on a device that *does* still have TuneIn registered, or by first registering it with a known-working station:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <speaker-ip> source content \
|
||||
--source TUNEIN --type stationurl \
|
||||
--location /v1/playback/station/s166521 \
|
||||
--name 'SMOOTH JAZZ'
|
||||
```
|
||||
|
||||
(Station `s166521` is one that works for AfterTouch testing; any valid TuneIn station ID works.)
|
||||
|
||||
Once the source plays once, it gets persisted to `/mnt/nv/BoseApp-Persistence/1/Sources.xml` and subsequent TuneIn requests succeed without needing the app.
|
||||
|
||||
**For speakers without SSH:**
|
||||
|
||||
If `soundtouch-cli source content --source TUNEIN ...` returns `1005` on a reset device that has never had TuneIn, the speaker is refusing because the source isn't registered yet — chicken-and-egg. The SoundTouch app is then the only practical path to register it; we can't write `Sources.xml` directly over telnet on most models.
|
||||
|
||||
## 🔊 **Volume & Audio Issues**
|
||||
|
||||
### ❌ "Volume control not working"
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
# Stockholm Backend — Port Guide for Bose-SoundTouch (Go)
|
||||
|
||||
This document describes everything needed to integrate the
|
||||
[krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app)
|
||||
functionality into the Go service. It is written as a reference; nothing here
|
||||
implies a specific file layout or package structure.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [What needs porting](#1-what-needs-porting)
|
||||
2. [What becomes obsolete](#2-what-becomes-obsolete)
|
||||
3. [Startup: Stockholm frontend preparation](#3-startup-stockholm-frontend-preparation)
|
||||
4. [Native bridge — appSend / runQueue](#4-native-bridge--appsend--runqueue)
|
||||
5. [State persistence — native-state.json](#5-state-persistence--native-statejson)
|
||||
6. [HTTP proxy — /api/http-proxy](#6-http-proxy--apihttp-proxy)
|
||||
7. [Browser bootstrap injection](#7-browser-bootstrap-injection)
|
||||
8. [SSDP discovery](#8-ssdp-discovery)
|
||||
9. [Config file structure (stockholm/json/config.json)](#9-config-file-structure-stockholmjsonconfigjson)
|
||||
10. [Backend config (backend-config.json)](#10-backend-config-backend-configjson)
|
||||
11. [Running as a plain process (no Docker)](#11-running-as-a-plain-process-no-docker)
|
||||
|
||||
---
|
||||
|
||||
## 1. What needs porting
|
||||
|
||||
| Component | Java class / file | Notes |
|
||||
|-----------------------------------------------|---------------------------------------|------------------------------------------------------|
|
||||
| Stockholm zip extraction + patch application | `docker-entrypoint.sh` | Shell; can be Go at startup |
|
||||
| URL rewriting in `stockholm/json/config.json` | `update-urls.sh` | Shell + `jq`/`sed`; can be Go |
|
||||
| Native bridge | `NativeBridgeService` | Core; per-tab message queue |
|
||||
| State persistence | `NativeBridgeService` (file I/O) | JSON file read/written on every `setData` |
|
||||
| HTTP proxy | `HttpProxyService` | CORS proxy + cloud header injection |
|
||||
| Browser bootstrap injection | `BackendApplication` (static handler) | Injects `<script>` into `index.html` |
|
||||
| SSDP speaker + media-server discovery | `SsdpDiscoveryService` | Already partially in Bose-SoundTouch |
|
||||
| Config reading | `SoundcorkDataService` | Reads `config.json` + `override.json` |
|
||||
| Backend config | `BackendConfig` | Single JSON file, only `frontendLoggingLevel` so far |
|
||||
|
||||
---
|
||||
|
||||
## 2. What becomes obsolete
|
||||
|
||||
When Bose-SoundTouch serves Stockholm directly, the following env vars and
|
||||
concepts collapse because the Go service knows its own URLs:
|
||||
|
||||
| Variable | Why it disappears |
|
||||
|------------------------------------|--------------------------------------------|
|
||||
| `BACKEND_URL` | Go service knows its own base URL |
|
||||
| `STREAMING_URL` | Same — the marge path is internal |
|
||||
| `AUTH_SERVICE_URL` | Same — marge is a local handler |
|
||||
| `BACKEND_BIND_IP` / `BACKEND_PORT` | Replaced by existing `PORT` / `HTTPS_PORT` |
|
||||
| `update-urls.sh` | Config rewriting becomes Go startup logic |
|
||||
| Custom CA cert via `keytool` | Replaced by Bose-SoundTouch `certmanager` |
|
||||
|
||||
What does **not** disappear:
|
||||
|
||||
- `MARGE_AUTH_TOKEN` / `MARGE_ACCOUNT_ID` — seeding initial session state
|
||||
- Stockholm zip + versioned patch files — still needed as assets
|
||||
- `PREFERRED_DEVICES` and other existing Bose-SoundTouch config
|
||||
|
||||
---
|
||||
|
||||
## 3. Startup: Stockholm frontend preparation
|
||||
|
||||
### 3a. Zip extraction
|
||||
|
||||
Source file: `docker-entrypoint.sh:prepare_stockholm()`
|
||||
|
||||
Look for `stockholm/index.html`. If absent:
|
||||
|
||||
1. Find the zip in `stockholm_zip/stockholm.zip` (preferred) or `stockholm.zip`
|
||||
alongside the binary.
|
||||
2. Extract the zip into `stockholm/`.
|
||||
|
||||
### 3b. Versioned patch application
|
||||
|
||||
Patch files are named `stockholm-changes_v<N>.patch` and applied in ascending
|
||||
order. The set is scanned dynamically at preparation time; today the upstream
|
||||
project ships v1 (1 153 lines), v2 (1 475 lines), v3 (44 lines, `now_play.js`
|
||||
fix), and v4 (68 lines, `app_comm.js` clientId polish). New versions added
|
||||
upstream are picked up automatically — our code does not hardcode a list.
|
||||
|
||||
A marker file `stockholm/.soundcork-stockholm-app.json` tracks the last applied
|
||||
version:
|
||||
```json
|
||||
{"project":"soundcork-stockholm-app","patchVersion":2}
|
||||
```
|
||||
|
||||
Algorithm:
|
||||
|
||||
1. Read `patchVersion` from the marker (default 0).
|
||||
2. For each `stockholm-changes_v<N>.patch` with N > current version, in order:
|
||||
- Strip hunks that don't touch `stockholm/` paths (the patch files include
|
||||
README and self-referential hunks).
|
||||
- Dry-run `patch -p1 -R` (reverse) to test if it's already applied.
|
||||
- Dry-run `patch -p1` (forward) to test if it can apply.
|
||||
- Apply with `patch -p1 --batch`.
|
||||
- Write the marker for version N.
|
||||
3. For v1 only, run `prettier --write "stockholm/**/*.js"` before patching
|
||||
(the patch was generated against formatted source).
|
||||
|
||||
The `patch` and `prettier` (npm) binaries are required. In a container image
|
||||
these are install-time dependencies. For a plain binary distribution they must
|
||||
be present on the host.
|
||||
|
||||
### 3c. Copy update-urls.sh into place
|
||||
|
||||
Copy `update-urls.sh` to `stockholm/json/update-urls.sh` after extraction.
|
||||
The script is called from that directory so relative paths work.
|
||||
|
||||
### 3d. Rewrite config.json URLs (replaces update-urls.sh)
|
||||
|
||||
`stockholm/json/config.json` stores most values base64-encoded under a
|
||||
`"default"` key (`d0`…`d13`). `update-urls.sh` decodes, rewrites with `sed`,
|
||||
and re-encodes.
|
||||
|
||||
When the Go service knows its own URLs at startup, it can do this in-process:
|
||||
|
||||
```
|
||||
fields to rewrite (sed substitutions in the shell script):
|
||||
streaming.bose.com → STREAMING_URL (default: BACKEND_URL, soundcork: BACKEND_URL/marge)
|
||||
events.api.bosecm.com → BACKEND_URL
|
||||
content.api.bose.io → BACKEND_URL
|
||||
worldwide.bose.com → BACKEND_URL
|
||||
downloads.bose.com → BACKEND_URL
|
||||
d6 field → AUTH_SERVICE_URL (set via jq, not sed)
|
||||
```
|
||||
|
||||
The Go equivalent:
|
||||
1. Read `config.json`, base64-decode each value in `default`.
|
||||
2. Replace the hostnames above.
|
||||
3. Set `default.d6` to the auth service URL.
|
||||
4. Re-encode all values in `default` as base64.
|
||||
5. Write back.
|
||||
|
||||
---
|
||||
|
||||
## 4. Native bridge — appSend / runQueue
|
||||
|
||||
Source: `NativeBridgeService.java`
|
||||
|
||||
Stockholm communicates with the native layer through two HTTP endpoints. The
|
||||
bridge emulates the Android `Native` object.
|
||||
|
||||
### Endpoints
|
||||
|
||||
```
|
||||
POST /api/native/appSend?clientId=<id> (or X-Stockholm-Client-Id header)
|
||||
GET /api/native/runQueue?clientId=<id>
|
||||
```
|
||||
|
||||
`clientId` is a per-browser-tab identifier. Falls back to `"default"`.
|
||||
|
||||
### appSend request body
|
||||
|
||||
JSON:
|
||||
```json
|
||||
{"method":"<name>","params":{...},"id":<number or null>}
|
||||
```
|
||||
|
||||
### runQueue response body
|
||||
|
||||
```json
|
||||
{"messages": [<message>, ...] | null}
|
||||
```
|
||||
|
||||
Each message is one of:
|
||||
|
||||
**Callback result** (response to a `getData`, `getConstant`, etc.):
|
||||
```json
|
||||
{"result":<value>,"error":<value or null>,"id":<id from request>}
|
||||
```
|
||||
|
||||
**Push method** (unsolicited, e.g. device discovery results):
|
||||
```json
|
||||
{"method":"devices","params":[...],"id":null}
|
||||
```
|
||||
|
||||
### Supported methods
|
||||
|
||||
| Method | Action |
|
||||
|---------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
|
||||
| `locale`, `htmlReady`, `stopHrmsUpdates` | No-op |
|
||||
| `log` | Log `params.msg` at debug level |
|
||||
| `setData` | Store `params.name` → `params.value` in state; persist to disk |
|
||||
| `getData` | Return state value for `params.name`; empty string if absent |
|
||||
| `getLanStatus` | Return `{"result":true,"error":null,"id":<id>}` |
|
||||
| `getTimeZone` | Return `{"result":{"timezoneInfo":"<IANA zone>","timeFormat":"TIME_FORMAT_24HOUR_ID"}}` |
|
||||
| `getLegalDocPath` | Return path string (see below) |
|
||||
| `getConstant` | Return `state["constant.<name>"]`; default for `"kilo"` is `"a7928d7b43dcd49f0af31e5aeed26458"` |
|
||||
| `canPerformAutoAPSetup` | Return `{"result":{"permission":false,"location":false}}` |
|
||||
| `getDeviceList` | Run SSDP renderer discovery async; push incremental `"devices"` messages |
|
||||
| `getHrmsList` | Run SSDP server discovery async; push `"servers"` message |
|
||||
| `getNetStats`, `getSSIDList`, `setSSID`, `updateSetting`, `oauth`, `downloadNewGui`, `installNewGui`, `sendLogs`, `socketCreate`, `socketSend`, `socketClose` | Return error `"unsupported"` |
|
||||
|
||||
**getLegalDocPath logic:**
|
||||
```
|
||||
type=lcns → "legal/platform_license.txt"
|
||||
type=<blank> → "legal/eula_en.txt"
|
||||
type=<type>,lang=<lang> → "legal/<type>_<lang>.txt" (lang defaults to "en")
|
||||
```
|
||||
|
||||
### Async discovery pattern
|
||||
|
||||
`getDeviceList` and `getHrmsList` are fired asynchronously. Each discovered
|
||||
device is pushed to the client queue immediately via `"devices"` / `"servers"`
|
||||
method messages before the discovery is complete. The frontend polls
|
||||
`/api/native/runQueue` continuously, so results arrive as they come in.
|
||||
|
||||
### Queue structure
|
||||
|
||||
One deque per `clientId`. `appSend` appends; `runQueue` drains the whole deque
|
||||
atomically and returns all pending messages.
|
||||
|
||||
### State seeding from environment
|
||||
|
||||
On startup, read these env vars and write to state if present:
|
||||
|
||||
| Env var | State key |
|
||||
|----------------------------------------|------------------|
|
||||
| `MARGE_AUTH_TOKEN` or `margeAuthToken` | `margeAuthToken` |
|
||||
| `MARGE_ACCOUNT_ID` or `margeAccountID` | `margeAccountID` |
|
||||
|
||||
Also seed on first run:
|
||||
|
||||
| State key | Value |
|
||||
|----------------------|-----------------------------------------------------------------|
|
||||
| `guid` | Random UUID (hex, no dashes) |
|
||||
| `deviceGuid` | Same UUID as `guid` |
|
||||
| `nativeFrameVersion` | Short version prefix extracted from `bose_app` in `config.json` |
|
||||
| `frame_version` | Full version from `bose_app` |
|
||||
| `authServer` | `"0"` |
|
||||
| `constant.kilo` | `"a7928d7b43dcd49f0af31e5aeed26458"` |
|
||||
|
||||
---
|
||||
|
||||
## 5. State persistence — native-state.json
|
||||
|
||||
Source: `NativeBridgeService.loadState()` / `persistState()`
|
||||
|
||||
File path (relative to workspace root): `backend/state/native-state.json`
|
||||
|
||||
Format: flat JSON object, all values are strings.
|
||||
|
||||
```json
|
||||
{
|
||||
"guid": "abc123...",
|
||||
"deviceGuid": "abc123...",
|
||||
"frame_version": "27.0.13",
|
||||
"nativeFrameVersion": "27.0.13",
|
||||
"authServer": "0",
|
||||
"margeAuthToken": "<token>",
|
||||
"margeAccountID": "1234567",
|
||||
"overrideMargeURL": "https://...",
|
||||
"overrideUpdateURL": "https://...",
|
||||
"constant.kilo": "a7928d7b43dcd49f0af31e5aeed26458",
|
||||
... (arbitrary keys from setData calls)
|
||||
}
|
||||
```
|
||||
|
||||
Written on every `setData` call and on initial seeding. Read once at startup.
|
||||
|
||||
---
|
||||
|
||||
## 6. HTTP proxy — /api/http-proxy
|
||||
|
||||
Source: `HttpProxyService.java`
|
||||
|
||||
Stockholm makes all cloud API calls through this proxy to work around browser
|
||||
CORS restrictions.
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
<ANY METHOD> /api/http-proxy?url=<url-encoded target URL>
|
||||
```
|
||||
|
||||
### Header filtering
|
||||
|
||||
**Blocked outbound (not forwarded to target):**
|
||||
```
|
||||
access-control-request-headers, access-control-request-method, connection,
|
||||
content-length, cookie, forwarded, host, http2-settings, keep-alive, origin,
|
||||
proxy-authenticate, proxy-authorization, referer, sec-ch-ua, sec-ch-ua-mobile,
|
||||
sec-ch-ua-platform, sec-fetch-dest, sec-fetch-mode, sec-fetch-site,
|
||||
sec-fetch-user, te, trailer, transfer-encoding, upgrade, x-forwarded-for,
|
||||
x-forwarded-host, x-forwarded-port, x-forwarded-proto, x-real-ip,
|
||||
x-requested-with
|
||||
```
|
||||
|
||||
**Blocked inbound (not relayed to browser):**
|
||||
```
|
||||
access-control-allow-credentials, access-control-allow-headers,
|
||||
access-control-allow-methods, access-control-allow-origin,
|
||||
access-control-expose-headers, access-control-max-age, connection,
|
||||
content-length, keep-alive, proxy-authenticate, proxy-authorization,
|
||||
set-cookie, set-cookie2, te, trailer, transfer-encoding, upgrade
|
||||
```
|
||||
|
||||
Also block HTTP/2 pseudo-headers (names starting with `:`).
|
||||
|
||||
Always add `Cache-Control: no-store` to the response.
|
||||
|
||||
### Backend-injected headers
|
||||
|
||||
Injected only if not already present in the request.
|
||||
|
||||
**BMX targets** (host is `content.api.bose.io`, `*.apigee.net`,
|
||||
`bose-prod.apigee.net`, `test.content.api.bose.io`):
|
||||
```
|
||||
x-bmx-api-key: <encryptedBmxToken from config.json d7>
|
||||
x-software-version: <bose_app version>
|
||||
```
|
||||
|
||||
**Marge targets** (host ends with `.bose.com` or `.apigee.net` AND path
|
||||
contains `/streaming/` or `/customer/`):
|
||||
```
|
||||
Accept: application/vnd.bose.streaming-v<N>+xml
|
||||
(or customer variant if path contains /customer/)
|
||||
Content-Type: same as Accept
|
||||
ClientType: SOUNDTOUCH_COMPUTER_APP
|
||||
GUID: <guid from state>
|
||||
version_NativeFrameVersion: <nativeFrameVersion from state>
|
||||
version_StockholmVersion: <bose_app version>
|
||||
version_ProtocolVersion: <bose_protocol version>
|
||||
<margeServerKeyHeader>: <margeServerKey> (if config d13/d10 non-empty)
|
||||
Authorization: <margeAuthToken> (not injected on login/environment endpoints)
|
||||
```
|
||||
|
||||
Authorization is **not** injected for these paths:
|
||||
- `*/streaming/account/login`
|
||||
- `/streaming/account` or `/streaming/account/`
|
||||
- `*/streaming/account/email/*/environment`
|
||||
- `/customer/account/password/email/*`
|
||||
|
||||
### Login retry (environment switching)
|
||||
|
||||
After a login `POST` to `*/streaming/account/login`:
|
||||
|
||||
1. If the response XML contains `<status-code>4033</status-code>` (wrong
|
||||
region), parse the login request body for `<username>` and `<password>`.
|
||||
2. Fetch `GET <same-origin><marge-prefix>/streaming/account/email/<email>/environment`
|
||||
with `Authorization: Basic <base64(email:password)>`.
|
||||
3. Parse the environment response XML for `<streamingURL>` and `<updateURL>`.
|
||||
4. Store both as `overrideMargeURL` / `overrideUpdateURL` in state.
|
||||
5. Retry the original login against the new `streamingURL`.
|
||||
|
||||
Subsequent marge requests are automatically redirected to `overrideMargeURL`
|
||||
via `SoundcorkDataService.overrideTarget()`.
|
||||
|
||||
### Session capture
|
||||
|
||||
After a successful login response (2xx):
|
||||
- Extract `<account id="...">` from the response XML body → store as `margeAccountID`.
|
||||
- Extract `Credentials` response header → store as `margeAuthToken`.
|
||||
|
||||
On any marge response:
|
||||
- If there is a `Refresh` response header, store its value as `margeAuthToken`.
|
||||
|
||||
### Proxy loop detection
|
||||
|
||||
Reject requests whose target URL resolves to the proxy's own
|
||||
`/api/http-proxy` endpoint. Considers both the direct bind address and the
|
||||
externally visible address from `X-Forwarded-Host` / `X-Forwarded-Port` /
|
||||
`Host` headers.
|
||||
|
||||
### Header value sanitisation
|
||||
|
||||
Drop header values that are `null`, `undefined`, or empty string (these can
|
||||
come from the Stockholm JS).
|
||||
|
||||
---
|
||||
|
||||
## 7. Browser bootstrap injection
|
||||
|
||||
Source: `BackendApplication.StaticStockholmHandler`
|
||||
|
||||
On every request to `index.html` or `setup/index.html`, inject a `<script>`
|
||||
block before `</head>`. The script is skipped if `window.StockholmBrowserBootstrap`
|
||||
already exists.
|
||||
|
||||
The injected JSON payload:
|
||||
```json
|
||||
{
|
||||
"authServer": "<0–3, from state>",
|
||||
"guid": "<guid from state>",
|
||||
"nativeVersion": "<frame_version from state>",
|
||||
"frameConfig": {}
|
||||
}
|
||||
```
|
||||
|
||||
The script does four things:
|
||||
1. Patches `window.getURLParams` to return `bootstrap.authServer`, `bootstrap.guid`,
|
||||
and `bootstrap.nativeVersion` for the keys `authServer`, `guid`, and
|
||||
`native_version` when the original function returns null.
|
||||
2. Patches `window.getUserAgentValue` to return `bootstrap.guid` for `_app`
|
||||
when the original returns empty.
|
||||
3. Sets `window.guid`, `window.frame_version`, `window.auth_server` from
|
||||
bootstrap values when they are empty.
|
||||
4. Patches `window.settingsLoad` to merge `bootstrap.frameConfig` into the
|
||||
config object (keys `f<N>` → `d<N>`, base64-encoded, only if currently
|
||||
empty).
|
||||
|
||||
`authServer` is an integer string `"0"`–`"3"`. The Java code normalises to
|
||||
`"0"` for any invalid value.
|
||||
|
||||
### Static file serving
|
||||
|
||||
Serve everything under `stockholm/` for all paths. Content types:
|
||||
|
||||
| Extension | MIME type |
|
||||
|----------------|-----------------------------------------|
|
||||
| `.html` | `text/html; charset=UTF-8` |
|
||||
| `.js` | `application/javascript; charset=UTF-8` |
|
||||
| `.css` | `text/css; charset=UTF-8` |
|
||||
| `.json` | `application/json; charset=UTF-8` |
|
||||
| `.xml` | `application/xml; charset=UTF-8` |
|
||||
| `.svg` | `image/svg+xml` |
|
||||
| `.png` | `image/png` |
|
||||
| `.jpg`/`.jpeg` | `image/jpeg` |
|
||||
| `.gif` | `image/gif` |
|
||||
| `.ttf` | `font/ttf` |
|
||||
| `.otf` | `font/otf` |
|
||||
| `.txt` | `text/plain; charset=UTF-8` |
|
||||
|
||||
Set `Cache-Control: no-store` on all responses.
|
||||
|
||||
For `HEAD` requests send headers only (no body, status -1 in content-length).
|
||||
For 204/304 responses send no body.
|
||||
|
||||
Path traversal: reject any path that resolves outside `stockholm/`.
|
||||
|
||||
### Frontend logging cookie
|
||||
|
||||
Set a `Set-Cookie` header on every static response:
|
||||
- If `frontendLoggingLevel > 0`:
|
||||
`stockholmFrontendLoggingLevel=<level>; Path=/; SameSite=Lax`
|
||||
- Otherwise (clear it):
|
||||
`stockholmFrontendLoggingLevel=; Max-Age=0; Path=/; SameSite=Lax`
|
||||
|
||||
---
|
||||
|
||||
## 8. SSDP discovery
|
||||
|
||||
Source: `SsdpDiscoveryService.java`
|
||||
|
||||
Bose-SoundTouch already has SSDP/UPnP discovery in `pkg/discovery`. The
|
||||
Stockholm bridge needs two specific discovery types with specific result shapes.
|
||||
|
||||
### Renderer discovery (speakers) — `getDeviceList`
|
||||
|
||||
Search target: `urn:schemas-upnp-org:device:MediaRenderer:1`
|
||||
|
||||
For each SSDP response, extract the `Location` header URL, take the `host`
|
||||
part, then fetch `GET http://<host>:8090/info`.
|
||||
|
||||
Parse the XML response:
|
||||
```xml
|
||||
<info deviceID="AA:BB:CC:DD:EE:FF">
|
||||
...
|
||||
<margeAccountUUID>1234567</margeAccountUUID>
|
||||
...
|
||||
</info>
|
||||
```
|
||||
|
||||
- `deviceID` attribute → `uID` (uppercased)
|
||||
- `margeAccountUUID` element text → `accountId`
|
||||
|
||||
Filter: if `margeAccountID` is set in state, only include speakers whose
|
||||
`margeAccountUUID` matches.
|
||||
|
||||
Result payload per speaker:
|
||||
```json
|
||||
{"uID": "AA:BB:CC:DD:EE:FF", "ip": "192.168.1.10"}
|
||||
```
|
||||
|
||||
Push incremental results as they arrive (push one device at a time via the
|
||||
`"devices"` method message). At the end, if the list is empty, push an empty
|
||||
`"devices"` message.
|
||||
|
||||
### Network interface selection for SSDP
|
||||
|
||||
Priority order: ethernet/en* > wifi/wl* > others.
|
||||
|
||||
Exclude: loopback, virtual, docker, vbox, vmware, hyper-v, bluetooth, teredo,
|
||||
tunnel interfaces.
|
||||
Require: IPv4 address, multicast support, interface up.
|
||||
|
||||
Try each interface in priority order; return results from the first one that
|
||||
gets responses.
|
||||
|
||||
SSDP probe parameters:
|
||||
- Multicast: `239.255.255.250:1900`
|
||||
- 3 probes, 350 ms between probes
|
||||
- 1 250 ms grace period after last probe
|
||||
- `MX: 1`
|
||||
|
||||
### Media server discovery (HRMS) — `getHrmsList`
|
||||
|
||||
Search target: `urn:schemas-upnp-org:device:MediaServer:1`
|
||||
|
||||
No HTTP fetch needed — extract from SSDP response headers only.
|
||||
|
||||
Result payload per server:
|
||||
```json
|
||||
{"uID": "<usn uuid or host:port>", "ip": "<host>", "port": "<port>"}
|
||||
```
|
||||
|
||||
`uID` is the UUID portion of the `USN` header (strip `uuid:` prefix and
|
||||
anything after `::`). Fall back to `host:port` if USN is absent.
|
||||
|
||||
Push all results at once (no incremental push) via `"servers"` method message.
|
||||
|
||||
---
|
||||
|
||||
## 9. Config file structure (stockholm/json/config.json)
|
||||
|
||||
Source: `SoundcorkDataService.java`
|
||||
|
||||
The file has three top-level objects: `app_versions`, `api_versions`, `default`.
|
||||
|
||||
### app_versions
|
||||
|
||||
| Key | Used as |
|
||||
|-----------------|-------------------------------------------------------------------------------|
|
||||
| `bose_app` | `soundcorkAppVersion` — also `x-software-version`, `version_StockholmVersion` |
|
||||
| `bose_protocol` | `protocolVersion` — sent as `version_ProtocolVersion` |
|
||||
|
||||
### api_versions
|
||||
|
||||
| Key | Used as |
|
||||
|------------------|--------------------------------------------------------------------------|
|
||||
| `bose_streaming` | Streaming API version — builds `application/vnd.bose.streaming-v<N>+xml` |
|
||||
| `bose_customer` | Customer API version — builds `application/vnd.bose.customer-v<N>+xml` |
|
||||
|
||||
### default (all values base64-encoded)
|
||||
|
||||
| Field | Content | Used as |
|
||||
|-------|------------------------------|--------------------------------------------------------------|
|
||||
| `d0` | marge base URL | `defaultMargeUrl` (redirected from `streaming.bose.com`) |
|
||||
| `d1` | update base URL | `defaultUpdateUrl` (redirected from `events.api.bosecm.com`) |
|
||||
| `d3` | BMX registry URL | `defaultBmxRegistryUrl` |
|
||||
| `d6` | auth service URL | Written by `update-urls.sh` / `AUTH_SERVICE_URL` |
|
||||
| `d7` | BMX API token | `encryptedBmxToken` — injected as `x-bmx-api-key` |
|
||||
| `d8` | BMX server alt URL | stored but not currently used in header injection |
|
||||
| `d10` | marge server key | injected as `<margeServerKeyHeader>` value on marge requests |
|
||||
| `d13` | marge server key header name | the header name for d10 |
|
||||
|
||||
### override.json
|
||||
|
||||
Sits alongside `config.json` at `stockholm/json/override.json`. Currently only
|
||||
`kilo` is read from it (not used in any live code path yet).
|
||||
|
||||
---
|
||||
|
||||
## 10. Backend config (backend-config.json)
|
||||
|
||||
Source: `BackendConfig.java`
|
||||
|
||||
File path: `backend/config/backend-config.json`
|
||||
|
||||
```json
|
||||
{"frontendLoggingLevel": 2}
|
||||
```
|
||||
|
||||
`frontendLoggingLevel`:
|
||||
- `0` — disable frontend debug logging (clear the cookie)
|
||||
- `> 0` — enable at that level (set cookie to the numeric value)
|
||||
|
||||
The Stockholm JS reads `stockholmFrontendLoggingLevel` from a cookie on load.
|
||||
|
||||
---
|
||||
|
||||
## 11. Running as a plain process (no Docker)
|
||||
|
||||
The entrypoint script does three things beyond launching the JVM. For a plain
|
||||
process, do these steps once before running the binary:
|
||||
|
||||
### Step 1 — extract and patch Stockholm
|
||||
|
||||
```shell
|
||||
# Requires: unzip, patch, npm/prettier@3.8.3
|
||||
unzip stockholm_zip/stockholm.zip -d stockholm
|
||||
npx prettier@3.8.3 --ignore-path /dev/null --write "stockholm/**/*.js"
|
||||
# Apply every stockholm-changes_v<N>.patch that exists, in ascending order.
|
||||
# Today the upstream ships v1..v4; new ones get picked up automatically when
|
||||
# the upstream repo is re-cloned via `make build-stockholm-image`.
|
||||
for patch in stockholm-changes_v*.patch; do
|
||||
patch -p1 --batch < "$patch"
|
||||
done
|
||||
```
|
||||
|
||||
Or run the Docker container once and copy the `stockholm/` directory out.
|
||||
|
||||
### Step 2 — rewrite URLs in config.json
|
||||
|
||||
```shell
|
||||
cd stockholm/json
|
||||
BACKEND_URL=http://localhost:8000 \
|
||||
STREAMING_URL=http://localhost:8000/marge \ # soundcork only
|
||||
AUTH_SERVICE_URL=http://localhost:8000/marge/ \
|
||||
source update-urls.sh
|
||||
cd ../..
|
||||
```
|
||||
|
||||
For Bose-SoundTouch, this step disappears — the Go service rewrites config.json
|
||||
in-process at startup.
|
||||
|
||||
### Step 3 — create state directory
|
||||
|
||||
```shell
|
||||
mkdir -p backend/state
|
||||
```
|
||||
|
||||
### Step 4 — run
|
||||
|
||||
```shell
|
||||
# Java (current):
|
||||
./gradlew run
|
||||
|
||||
# Go (future):
|
||||
./soundtouch-service # with appropriate env vars
|
||||
```
|
||||
|
||||
The Java `resolveWorkspaceRoot()` searches for a `stockholm/` directory at CWD
|
||||
or one level up. Run from the project root.
|
||||
|
||||
---
|
||||
|
||||
## Patches summary — what the Stockholm JS patches do
|
||||
|
||||
**v1** (the main patch, applied after prettier formatting):
|
||||
|
||||
- `stockholm/index.html` — adds `<meta>` charset and viewport tags
|
||||
- `stockholm/js/app_comm.js` — rewrites `AppComm` to use the HTTP native bridge
|
||||
(`/api/native/appSend` + `/api/native/runQueue`) instead of Android native calls
|
||||
- `stockholm/js/browser_http_proxy.js` — **new file** — implements the
|
||||
`stHttpProxy` function that routes all cloud API calls through `/api/http-proxy`
|
||||
- `stockholm/js/browser_native_bridge.js` — **new file** — implements
|
||||
`window.Native` shim that calls the bridge endpoints
|
||||
- `stockholm/js/main.js` — wires up the browser native bridge on load
|
||||
- `stockholm/setup/index.html` — same charset/viewport fix
|
||||
- `stockholm/setup/js/app_comm.js` — same AppComm bridge rewrite for the setup flow
|
||||
|
||||
**v2** (incremental fixes on top of v1):
|
||||
|
||||
- `stockholm/js/app_comm.js` — additional fixes and multi-tab `clientId` support
|
||||
- `stockholm/js/browser_native_bridge.js` — minor fix
|
||||
- `stockholm/js/main.js` — minor fix
|
||||
- `stockholm/js/marge_comm.js` — fixes marge URL handling
|
||||
- `stockholm/js/presets.js` — minor fix
|
||||
- `stockholm/js/sources.js` — minor fix
|
||||
- `stockholm/setup/js/app_comm.js` — same fixes as main app_comm.js
|
||||
|
||||
**v3** (44 lines):
|
||||
|
||||
- `stockholm/js/now_play.js` — small playback-state guard.
|
||||
|
||||
**v4** (68 lines):
|
||||
|
||||
- `stockholm/js/app_comm.js` — further `clientId` handling polish (localStorage persistence).
|
||||
@@ -96,22 +96,38 @@ func showCurrentPresets(c *client.Client) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(presets.Preset) == 0 {
|
||||
// Filter out placeholder presets (issue #308): self-closing
|
||||
// <preset/> entries from a factory-reset device and
|
||||
// INVALID_SOURCE placeholders from healthy devices both panic if
|
||||
// their fields are dereferenced directly.
|
||||
configured := make([]models.Preset, 0, len(presets.Preset))
|
||||
|
||||
for _, p := range presets.Preset {
|
||||
if !p.IsEmpty() {
|
||||
configured = append(configured, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(configured) == 0 {
|
||||
fmt.Println(" 📭 No presets configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf(" 📻 Found %d configured presets:\n", len(presets.Preset))
|
||||
for _, preset := range presets.Preset {
|
||||
fmt.Printf(" 📻 Found %d configured presets:\n", len(configured))
|
||||
|
||||
for _, preset := range configured {
|
||||
fmt.Printf(" %d. %s\n", preset.ID, preset.GetDisplayName())
|
||||
fmt.Printf(" Source: %s\n", preset.ContentItem.Source)
|
||||
if preset.ContentItem.Location != "" {
|
||||
fmt.Printf(" Location: %s\n", preset.ContentItem.Location)
|
||||
fmt.Printf(" Source: %s\n", preset.GetSource())
|
||||
|
||||
if location := preset.GetLocation(); location != "" {
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
}
|
||||
|
||||
if preset.CreatedOn != nil && *preset.CreatedOn != 0 {
|
||||
createdTime := time.Unix(*preset.CreatedOn, 0)
|
||||
fmt.Printf(" Created: %s\n", createdTime.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -71,9 +71,25 @@ func (p *Preset) IsSpotifyPreset() bool {
|
||||
return p.ContentItem != nil && p.ContentItem.Source == "SPOTIFY"
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the preset has no content
|
||||
// IsEmpty returns true if the preset has no playable content. Two
|
||||
// placeholder shapes are observed in the wild and both count as empty:
|
||||
//
|
||||
// - <preset/> (or <preset id="0"/>) — no ContentItem child at all.
|
||||
// Emitted by some firmware after a factory reset (issue #308).
|
||||
// - <preset id="0"><ContentItem source="INVALID_SOURCE" isPresetable="true"/></preset>
|
||||
// — a placeholder ContentItem the firmware uses for unconfigured
|
||||
// slots, observed on FW 27.0.6 even on devices that were never
|
||||
// reset.
|
||||
//
|
||||
// Treating both as empty keeps GetEmptyPresetSlots, GetUsedPresetSlots
|
||||
// and HasPresets honest, and lets callers safely skip placeholders
|
||||
// before formatting a preset for display.
|
||||
func (p *Preset) IsEmpty() bool {
|
||||
return p.ContentItem == nil
|
||||
if p.ContentItem == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return p.ContentItem.Source == "" || p.ContentItem.Source == "INVALID_SOURCE"
|
||||
}
|
||||
|
||||
// GetSource returns the source of the preset content
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// reporterXML is the /presets response captured from the speaker that
|
||||
// crashed the CLI in issue #308 (ST10 post factory reset, FW 27.0.6).
|
||||
// Two configured presets followed by three self-closing <preset/>
|
||||
// placeholders. The original crash happened on the first <preset/>:
|
||||
// GetDisplayName() handled the nil ContentItem, but the very next
|
||||
// line dereferenced ContentItem.Source unconditionally.
|
||||
const reporterXML = `<presets>
|
||||
<preset id="1" createdOn="1348058580" updatedOn="1348058580">
|
||||
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s6634" sourceAccount="" isPresetable="true">
|
||||
<itemName>MDR JUMP</itemName>
|
||||
<containerArt/>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="2" createdOn="1348058580" updatedOn="1348058580">
|
||||
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s10637" sourceAccount="" isPresetable="true">
|
||||
<itemName>SUNSHINE LIVE</itemName>
|
||||
<containerArt>
|
||||
http://cdn-profiles.tunein.com/s10637/images/logog.png?t=637791086340000000
|
||||
</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset/>
|
||||
<preset/>
|
||||
<preset/>
|
||||
</presets>`
|
||||
|
||||
// invalidSourceXML is the second placeholder shape observed in the
|
||||
// wild (gesellix's ST10/ST20 on FW 27.0.6, never factory-reset). The
|
||||
// firmware here populates ContentItem with source="INVALID_SOURCE"
|
||||
// for unconfigured slots — non-nil but useless, so the old IsEmpty
|
||||
// (== nil only) returned false and the placeholders polluted listings.
|
||||
const invalidSourceXML = `<?xml version="1.0" encoding="UTF-8" ?><presets>` +
|
||||
`<preset id="0"><ContentItem source="INVALID_SOURCE" isPresetable="true" /></preset>` +
|
||||
`<preset id="0"><ContentItem source="INVALID_SOURCE" isPresetable="true" /></preset>` +
|
||||
`<preset id="0"><ContentItem source="INVALID_SOURCE" isPresetable="true" /></preset>` +
|
||||
`<preset id="1"><ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/abc" sourceAccount="user" isPresetable="true"><itemName>Sand Castle Tapes</itemName><containerArt></containerArt></ContentItem></preset>` +
|
||||
`<preset id="2" createdOn="1778965482" updatedOn="1778965482"><ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/def" sourceAccount="user" isPresetable="true"><itemName>Unplugged</itemName><containerArt>https://example.com/art.jpg</containerArt></ContentItem></preset>` +
|
||||
`<preset id="6"><ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s166521" sourceAccount="" isPresetable="true"><itemName>SMOOTH JAZZ</itemName><containerArt>https://example.com/logo.png</containerArt></ContentItem></preset>` +
|
||||
`</presets>`
|
||||
|
||||
func TestIsEmpty_NoContentItem(t *testing.T) {
|
||||
// Shape A: <preset/> — ContentItem == nil. This is the shape
|
||||
// behind the issue #308 crash.
|
||||
p := Preset{}
|
||||
if !p.IsEmpty() {
|
||||
t.Error("IsEmpty() should be true when ContentItem is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmpty_InvalidSourcePlaceholder(t *testing.T) {
|
||||
// Shape B: ContentItem present but Source == "INVALID_SOURCE".
|
||||
// Observed on devices that never had a factory reset.
|
||||
p := Preset{
|
||||
ContentItem: &ContentItem{Source: "INVALID_SOURCE", IsPresetable: true},
|
||||
}
|
||||
if !p.IsEmpty() {
|
||||
t.Error("IsEmpty() should be true when ContentItem has INVALID_SOURCE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmpty_EmptySource(t *testing.T) {
|
||||
// A ContentItem with no Source can't drive playback. Treat it
|
||||
// as empty too — defensive, not tied to a single observed shape.
|
||||
p := Preset{ContentItem: &ContentItem{}}
|
||||
if !p.IsEmpty() {
|
||||
t.Error("IsEmpty() should be true when ContentItem.Source is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmpty_RealPreset(t *testing.T) {
|
||||
p := Preset{
|
||||
ContentItem: &ContentItem{
|
||||
Source: "TUNEIN",
|
||||
ItemName: "MDR JUMP",
|
||||
},
|
||||
}
|
||||
if p.IsEmpty() {
|
||||
t.Error("IsEmpty() should be false for a configured preset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReporterXML_DoesNotPanicAndFiltersEmpty(t *testing.T) {
|
||||
// Reproducer for issue #308: simulate the loop that crashed the
|
||||
// CLI. The fix is two-fold: IsEmpty now recognises <preset/>,
|
||||
// and callers use the nil-safe Get* accessors. Walking every
|
||||
// preset through the same paths the CLI uses must not panic on
|
||||
// any entry.
|
||||
var presets Presets
|
||||
|
||||
if err := xml.Unmarshal([]byte(reporterXML), &presets); err != nil {
|
||||
t.Fatalf("Failed to unmarshal reporter XML: %v", err)
|
||||
}
|
||||
|
||||
if got := len(presets.Preset); got != 5 {
|
||||
t.Fatalf("Expected 5 preset entries (2 configured + 3 empty), got %d", got)
|
||||
}
|
||||
|
||||
emptyCount := 0
|
||||
configuredCount := 0
|
||||
|
||||
for _, p := range presets.Preset {
|
||||
// The CLI now skips empty presets before dereferencing
|
||||
// anything on ContentItem. The IsEmpty call must catch all
|
||||
// three <preset/> entries.
|
||||
if p.IsEmpty() {
|
||||
emptyCount++
|
||||
continue
|
||||
}
|
||||
|
||||
configuredCount++
|
||||
|
||||
// These calls would have panicked pre-fix on the empty
|
||||
// entries; here they exercise the still-printed paths for
|
||||
// the real ones.
|
||||
_ = p.GetDisplayName()
|
||||
_ = p.GetSource()
|
||||
_ = p.GetSourceAccount()
|
||||
_ = p.GetLocation()
|
||||
}
|
||||
|
||||
if emptyCount != 3 {
|
||||
t.Errorf("Expected 3 empty presets, got %d", emptyCount)
|
||||
}
|
||||
|
||||
if configuredCount != 2 {
|
||||
t.Errorf("Expected 2 configured presets, got %d", configuredCount)
|
||||
}
|
||||
|
||||
// HasPresets should reflect "there are real presets" — not
|
||||
// confused by the placeholders.
|
||||
if !presets.HasPresets() {
|
||||
t.Error("HasPresets() should be true (2 real presets present)")
|
||||
}
|
||||
|
||||
if got := presets.GetUsedPresetSlots(); len(got) != 2 {
|
||||
t.Errorf("GetUsedPresetSlots() = %v; want 2 entries", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidSourceXML_PlaceholdersFilteredOut(t *testing.T) {
|
||||
// Second-shape reproducer: three INVALID_SOURCE placeholders
|
||||
// preceding three real presets. Before the IsEmpty extension,
|
||||
// listings printed "0. Preset 0 / Source: INVALID_SOURCE" three
|
||||
// times before the real entries — annoying, not crashing.
|
||||
var presets Presets
|
||||
|
||||
if err := xml.Unmarshal([]byte(invalidSourceXML), &presets); err != nil {
|
||||
t.Fatalf("Failed to unmarshal invalid-source XML: %v", err)
|
||||
}
|
||||
|
||||
if got := len(presets.Preset); got != 6 {
|
||||
t.Fatalf("Expected 6 preset entries, got %d", got)
|
||||
}
|
||||
|
||||
configured := 0
|
||||
|
||||
for _, p := range presets.Preset {
|
||||
if !p.IsEmpty() {
|
||||
configured++
|
||||
}
|
||||
}
|
||||
|
||||
if configured != 3 {
|
||||
t.Errorf("Expected 3 configured presets (after filtering INVALID_SOURCE placeholders), got %d",
|
||||
configured)
|
||||
}
|
||||
|
||||
// The three placeholders all carry id="0", so used-slot
|
||||
// reporting should ignore them and show only the real ids.
|
||||
used := presets.GetUsedPresetSlots()
|
||||
if len(used) != 3 {
|
||||
t.Fatalf("GetUsedPresetSlots() = %v; want 3 entries", used)
|
||||
}
|
||||
|
||||
wantIDs := map[int]bool{1: true, 2: true, 6: true}
|
||||
for _, id := range used {
|
||||
if !wantIDs[id] {
|
||||
t.Errorf("Unexpected used slot id %d; want one of %v", id, []int{1, 2, 6})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package amazon
|
||||
|
||||
import "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
|
||||
|
||||
// ErrAddUserNoOp re-exports zeroconf.ErrAddUserNoOp for callers that don't
|
||||
// want a direct dependency on the zeroconf package.
|
||||
var ErrAddUserNoOp = zeroconf.ErrAddUserNoOp
|
||||
|
||||
// PushAmazonCredentials pushes Amazon Music credentials to a speaker using the
|
||||
// ZeroConf DH key exchange protocol. Falls back to simplified token push if
|
||||
// the speaker does not support DH (older firmware).
|
||||
|
||||
+258
-11
@@ -23,6 +23,17 @@ const (
|
||||
TuneInNavigateAshx = "http://opml.radiotime.com/?render=json"
|
||||
TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query="
|
||||
|
||||
// TuneInProfileContents is the modern JSON API that lists a
|
||||
// program's (`p<N>`) episodes. The legacy OPML endpoints can't —
|
||||
// `Tune.ashx?id=p<N>` returns `#STATUS: 400`, `Browse.ashx?id=p<N>`
|
||||
// only surfaces related genres + networks. Same payload is served
|
||||
// from api.tunein.com and api.radiotime.com; we use radiotime
|
||||
// because TuneInNavigateProfile already navigates there via
|
||||
// Pivots.Contents.Url, so all program-related traffic stays on the
|
||||
// same host that's already in allowedTuneInHosts. See
|
||||
// `_/i226/tunein-api-findings.md` for the full endpoint map.
|
||||
TuneInProfileContents = "https://api.radiotime.com/profiles/%s/contents?version=1.3"
|
||||
|
||||
// DefaultTuneInStreamFormats is the comma-separated format list
|
||||
// AfterTouch sends to TuneIn's Tune.ashx by default. Matches the
|
||||
// pre-2026-05-10 behaviour from before PR #249 added "hls"
|
||||
@@ -512,11 +523,26 @@ func tuneInSearchProfile(item map[string]interface{}, name string) models.BmxNav
|
||||
apiURL, _ := profile["Url"].(string)
|
||||
apiURLEncoded := base64.URLEncoding.EncodeToString([]byte(apiURL))
|
||||
|
||||
links := &models.Links{
|
||||
BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/profiles/%s/%s/%s", name, guideID, apiURLEncoded)},
|
||||
BmxPreset: &models.Link{ContainerArt: image, Href: fmt.Sprintf("/v1/preset/program/%s", guideID), Name: title, Type: "tracklisturl"},
|
||||
}
|
||||
|
||||
// Programs are containers, but with the `p` → `t` expansion in
|
||||
// TuneInPlaybackPodcast a single "play this program" click can now
|
||||
// route to the newest episode. Surface that as a BmxPlayback link so
|
||||
// the web UI renders a play button on the program card itself, not
|
||||
// just on individual episode cards reached by drilling in. Artists
|
||||
// stay navigate-only — there's no single sensible "play this artist"
|
||||
// stream.
|
||||
if name == "Program" && guideID != "" {
|
||||
encodedName := base64.URLEncoding.EncodeToString([]byte(title))
|
||||
playbackHref := fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", guideID, encodedName)
|
||||
links.BmxPlayback = &models.Link{Href: playbackHref, Type: "tracklisturl"}
|
||||
}
|
||||
|
||||
return models.BmxNavItem{
|
||||
Links: &models.Links{
|
||||
BmxNavigate: &models.Link{Href: fmt.Sprintf("/v1/navigate/profiles/%s/%s/%s", name, guideID, apiURLEncoded)},
|
||||
BmxPreset: &models.Link{ContainerArt: image, Href: fmt.Sprintf("/v1/preset/program/%s", guideID), Name: title, Type: "tracklisturl"},
|
||||
},
|
||||
Links: links,
|
||||
ImageUrl: image,
|
||||
Name: title,
|
||||
Subtitle: subtitle,
|
||||
@@ -539,10 +565,26 @@ func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
|
||||
profileTitle, _ := profileItem["Title"].(string)
|
||||
profileImage, _ := profileItem["Image"].(string)
|
||||
profileSubtitle, _ := profileItem["Subtitle"].(string)
|
||||
profileType, _ := profileItem["Type"].(string)
|
||||
profileGuideID, _ := profileItem["GuideId"].(string)
|
||||
|
||||
heroItem := models.BmxNavItem{Name: profileTitle, ImageUrl: profileImage, Subtitle: profileSubtitle}
|
||||
|
||||
// Surface "play latest episode" on the profile hero so users don't
|
||||
// have to scroll to the episode list. Matches the BmxPlayback link
|
||||
// emitted for Program cards in search results; the backend
|
||||
// p<N> → t<N> expansion resolves the actual stream.
|
||||
if profileType == "Program" && profileGuideID != "" {
|
||||
encodedName := base64.URLEncoding.EncodeToString([]byte(profileTitle))
|
||||
playbackHref := fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", profileGuideID, encodedName)
|
||||
heroItem.Links = &models.Links{
|
||||
BmxPlayback: &models.Link{Href: playbackHref, Type: "tracklisturl"},
|
||||
}
|
||||
}
|
||||
|
||||
sections := []models.BmxNavSection{
|
||||
{
|
||||
Items: []models.BmxNavItem{{Name: profileTitle, ImageUrl: profileImage, Subtitle: profileSubtitle}},
|
||||
Items: []models.BmxNavItem{heroItem},
|
||||
Layout: "hero",
|
||||
Name: "",
|
||||
},
|
||||
@@ -578,6 +620,194 @@ func TuneInNavigateProfile(encodedURI string) (*models.BmxNavResponse, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseTuneInStreamBody filters a Tune.ashx response body down to the
|
||||
// playable stream URLs. TuneIn responds with HTTP 200 even on errors,
|
||||
// embedding a `#STATUS: <code>` comment line in the body (the body is
|
||||
// pls/m3u-like, so `#`-prefixed lines are comments — including error
|
||||
// markers like `#STATUS: 400`). Without this filter the caller would
|
||||
// happily pass `#STATUS: 400` to the speaker as if it were a stream URL.
|
||||
//
|
||||
// Returns the cleaned list of URL strings (TrimSpaced, comment lines
|
||||
// dropped, empty lines dropped). Returns an error if no playable URL
|
||||
// remains so callers surface a real 500 instead of silently corrupting
|
||||
// the playback response.
|
||||
func parseTuneInStreamBody(body []byte, guideID string) ([]string, error) {
|
||||
raw := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||
out := make([]string, 0, len(raw))
|
||||
|
||||
for _, line := range raw {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, line)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("TuneIn returned no playable stream URL for guide-id %q (body: %q)",
|
||||
guideID, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// tuneInProfileContentsResponse models the subset of the
|
||||
// api.tunein.com/profiles/{id}/contents JSON we need to pick a
|
||||
// program's newest episode. The endpoint returns substantially more
|
||||
// fields per item; everything outside this struct is ignored.
|
||||
type tuneInProfileContentsResponse struct {
|
||||
Items []tuneInProfileContentsItem `json:"Items"`
|
||||
}
|
||||
|
||||
type tuneInProfileContentsItem struct {
|
||||
ContainerType string `json:"ContainerType"`
|
||||
Title string `json:"Title"`
|
||||
AccessibilityTitle string `json:"AccessibilityTitle"`
|
||||
Children []tuneInProfileContentsTopic `json:"Children"`
|
||||
}
|
||||
|
||||
type tuneInProfileContentsTopic struct {
|
||||
GuideId string `json:"GuideId"`
|
||||
Type string `json:"Type"`
|
||||
Title string `json:"Title"`
|
||||
Image string `json:"Image"`
|
||||
}
|
||||
|
||||
// parseTuneInProgramContents walks a profile/contents JSON body and
|
||||
// returns the guide-id of the newest playable episode. The contract:
|
||||
//
|
||||
// - Items[] entry with ContainerType=="Topics" and Title (or
|
||||
// AccessibilityTitle) equal to "Episodes" is treated as the
|
||||
// authoritative episode list.
|
||||
// - If no item matches by name, the first ContainerType=="Topics"
|
||||
// entry is used as fallback — TuneIn occasionally varies the
|
||||
// localised title.
|
||||
// - Inside the chosen container the first child with a `t`-prefixed
|
||||
// GuideId wins. TuneIn orders children newest-first.
|
||||
//
|
||||
// Returns a wrapped error if the body is malformed or contains no
|
||||
// playable topic; callers surface this as a 500 rather than handing
|
||||
// the speaker a broken stream URL.
|
||||
func parseTuneInProgramContents(body []byte, programID string) (episodeID string, err error) {
|
||||
var parsed tuneInProfileContentsResponse
|
||||
if decErr := json.Unmarshal(body, &parsed); decErr != nil {
|
||||
return "", fmt.Errorf("decode TuneIn profile/contents for %q: %w", programID, decErr)
|
||||
}
|
||||
|
||||
var fallback *tuneInProfileContentsItem
|
||||
|
||||
for i := range parsed.Items {
|
||||
item := &parsed.Items[i]
|
||||
if item.ContainerType != "Topics" {
|
||||
continue
|
||||
}
|
||||
|
||||
if fallback == nil {
|
||||
fallback = item
|
||||
}
|
||||
|
||||
if strings.EqualFold(item.Title, "Episodes") ||
|
||||
strings.EqualFold(item.AccessibilityTitle, "Episodes") {
|
||||
if id := firstTuneInTopicGuideID(item.Children); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fallback != nil {
|
||||
if id := firstTuneInTopicGuideID(fallback.Children); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no playable episode found in TuneIn profile/contents for program %q", programID)
|
||||
}
|
||||
|
||||
func firstTuneInTopicGuideID(children []tuneInProfileContentsTopic) string {
|
||||
for _, child := range children {
|
||||
if strings.HasPrefix(child.GuideId, "t") {
|
||||
return child.GuideId
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveTuneInProgramLatestEpisode fetches the program's profile from
|
||||
// api.tunein.com and returns the newest playable episode's topic
|
||||
// guide-id (the `t<N>` form Tune.ashx accepts). The legacy OPML
|
||||
// endpoints can't enumerate program episodes; see
|
||||
// `_/i226/tunein-api-findings.md` for the full endpoint contract.
|
||||
func resolveTuneInProgramLatestEpisode(programID string) (episodeID string, err error) {
|
||||
contentsURL := fmt.Sprintf(TuneInProfileContents, programID)
|
||||
|
||||
resp, err := http.Get(contentsURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TuneIn profile/contents returned status %d for program %q",
|
||||
resp.StatusCode, programID)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return parseTuneInProgramContents(body, programID)
|
||||
}
|
||||
|
||||
// TuneInDescribeMeta fetches just the display name and logo URL for a TuneIn
|
||||
// guide ID via the same describe endpoint TuneInPlayback uses. Useful for
|
||||
// CLI / UI enrichment that wants to populate ContentItem.ItemName +
|
||||
// ContainerArt before sending a SelectContentItem to the speaker — without
|
||||
// resolving the full stream URL.
|
||||
//
|
||||
// Returns empty strings (and a nil error) if the describe payload doesn't
|
||||
// contain a recognisable station / show element. Network errors and XML
|
||||
// decode errors surface verbatim.
|
||||
func TuneInDescribeMeta(id string) (name, logo string, err error) {
|
||||
describeURL := fmt.Sprintf(TuneInDescribe, id)
|
||||
|
||||
resp, err := http.Get(describeURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Same shape TuneInPlayback parses for stations. For programs and
|
||||
// episodes the describe endpoint returns analogous structures; if
|
||||
// the station element is absent the response yields empty strings
|
||||
// and the caller can fall back to user-supplied values.
|
||||
var opml struct {
|
||||
Body struct {
|
||||
Outline struct {
|
||||
Station struct {
|
||||
Name string `xml:"name"`
|
||||
Logo string `xml:"logo"`
|
||||
} `xml:"station"`
|
||||
} `xml:"outline"`
|
||||
} `xml:"body"`
|
||||
}
|
||||
|
||||
if uErr := xml.Unmarshal(body, &opml); uErr != nil {
|
||||
return "", "", uErr
|
||||
}
|
||||
|
||||
return opml.Body.Outline.Station.Name, opml.Body.Outline.Station.Logo, nil
|
||||
}
|
||||
|
||||
// TuneInPlayback resolves a live radio station and returns a Bose-compatible
|
||||
// playback response with primary stream and variants. formats is the
|
||||
// comma-separated list passed to Tune.ashx?formats=… ; empty falls back to
|
||||
@@ -628,9 +858,9 @@ func TuneInPlayback(stationID, formats string) (*models.BmxPlaybackResponse, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamURLList := strings.Split(strings.TrimSpace(string(streamBody)), "\n")
|
||||
if len(streamURLList) == 0 || streamURLList[0] == "" {
|
||||
return nil, fmt.Errorf("no streams found")
|
||||
streamURLList, err := parseTuneInStreamBody(streamBody, stationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamID := "e3342"
|
||||
@@ -725,7 +955,24 @@ func TuneInPodcastInfo(podcastID, encodedName string) (*models.BmxPodcastInfoRes
|
||||
// TuneInPlaybackPodcast resolves an on-demand podcast episode and returns
|
||||
// a playback response suitable for SoundTouch devices. formats has the
|
||||
// same semantics as in TuneInPlayback.
|
||||
//
|
||||
// Accepts three TuneIn guide-id shapes:
|
||||
// - `t<N>` — topic/episode; played directly.
|
||||
// - `e<N>` — live episode; played directly.
|
||||
// - `p<N>` — podcast program (a container, not a stream). Expanded
|
||||
// to its newest episode via the JSON profile/contents API before
|
||||
// resolving the stream URL. The legacy OPML `Tune.ashx?id=p<N>`
|
||||
// would return `#STATUS: 400` for this case.
|
||||
func TuneInPlaybackPodcast(podcastID, formats string) (*models.BmxPlaybackResponse, error) {
|
||||
if strings.HasPrefix(podcastID, "p") {
|
||||
episodeID, resolveErr := resolveTuneInProgramLatestEpisode(podcastID)
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
}
|
||||
|
||||
podcastID = episodeID
|
||||
}
|
||||
|
||||
describeURL := fmt.Sprintf(TuneInDescribe, podcastID)
|
||||
|
||||
resp, err := http.Get(describeURL)
|
||||
@@ -774,9 +1021,9 @@ func TuneInPlaybackPodcast(podcastID, formats string) (*models.BmxPlaybackRespon
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamURLList := strings.Split(strings.TrimSpace(string(streamBody)), "\n")
|
||||
if len(streamURLList) == 0 || streamURLList[0] == "" {
|
||||
return nil, fmt.Errorf("no streams found")
|
||||
streamURLList, err := parseTuneInStreamBody(streamBody, podcastID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamID := "e3342"
|
||||
|
||||
@@ -270,3 +270,265 @@ func TestTuneInStream_OverrideHonoured(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTuneInStreamBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantURLs []string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "single URL",
|
||||
body: "https://stream.example.com/foo.mp3\n",
|
||||
wantURLs: []string{"https://stream.example.com/foo.mp3"},
|
||||
},
|
||||
{
|
||||
name: "multiple URLs",
|
||||
body: "https://a/1.mp3\nhttps://b/2.mp3\n",
|
||||
wantURLs: []string{"https://a/1.mp3", "https://b/2.mp3"},
|
||||
},
|
||||
{
|
||||
// The bug behind PR #313's i314 follow-up — TuneIn 200's the
|
||||
// response body with `#STATUS: 400` for guide-ids that aren't
|
||||
// streamable (e.g. podcast program IDs sent to Tune.ashx).
|
||||
// Pre-fix, this string went out to the speaker as if it were a
|
||||
// stream URL.
|
||||
name: "comment-only body — TuneIn 400 error",
|
||||
body: "#STATUS: 400\n#description=Bad request\n",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "comments mixed with real URL",
|
||||
body: "#EXTM3U\nhttps://stream.example.com/foo.mp3\n#END\n",
|
||||
wantURLs: []string{"https://stream.example.com/foo.mp3"},
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: "",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "only blank lines",
|
||||
body: "\n\n \n",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "trims surrounding whitespace per line",
|
||||
body: " https://a/1.mp3 \n\thttps://b/2.mp3\t\n",
|
||||
wantURLs: []string{"https://a/1.mp3", "https://b/2.mp3"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseTuneInStreamBody([]byte(tc.body), "test-guide-id")
|
||||
|
||||
if tc.wantError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %v", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "test-guide-id") {
|
||||
t.Errorf("error should mention the guide-id for diagnosis: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != len(tc.wantURLs) {
|
||||
t.Fatalf("len mismatch: got %d (%v), want %d (%v)", len(got), got, len(tc.wantURLs), tc.wantURLs)
|
||||
}
|
||||
|
||||
for i := range got {
|
||||
if got[i] != tc.wantURLs[i] {
|
||||
t.Errorf("URL[%d] mismatch: got %q, want %q", i, got[i], tc.wantURLs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTuneInSearchProfileEmitsBmxPlayback pins the rule that
|
||||
// program-card play buttons appear in the web/CLI search UI: Program
|
||||
// search items get a BmxPlayback link (so the speaker hits our
|
||||
// podcast endpoint and the p<N> → t<N> expansion kicks in), while
|
||||
// Artist items stay navigate-only — there's no single sensible
|
||||
// stream for an artist.
|
||||
func TestTuneInSearchProfileEmitsBmxPlayback(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
profileName string
|
||||
guideID string
|
||||
wantPlayback bool
|
||||
wantType string
|
||||
}{
|
||||
{name: "Program with guide-id gets play link", profileName: "Program", guideID: "p290778", wantPlayback: true, wantType: "tracklisturl"},
|
||||
{name: "Artist with guide-id is navigate-only", profileName: "Artist", guideID: "a12345", wantPlayback: false},
|
||||
{name: "Program without guide-id is navigate-only", profileName: "Program", guideID: "", wantPlayback: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
item := map[string]interface{}{
|
||||
"GuideId": tc.guideID,
|
||||
"Title": "Die Nachrichten",
|
||||
"Image": "http://example.com/logo.png",
|
||||
"Subtitle": "Deutschlandfunk",
|
||||
"Actions": map[string]interface{}{
|
||||
"Profile": map[string]interface{}{
|
||||
"Url": "https://api.radiotime.com/profiles/" + tc.guideID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
navItem := tuneInSearchProfile(item, tc.profileName)
|
||||
|
||||
if navItem.Links == nil {
|
||||
t.Fatal("expected Links to be set")
|
||||
}
|
||||
|
||||
if tc.wantPlayback {
|
||||
if navItem.Links.BmxPlayback == nil {
|
||||
t.Fatal("expected BmxPlayback link for Program")
|
||||
}
|
||||
|
||||
if navItem.Links.BmxPlayback.Type != tc.wantType {
|
||||
t.Errorf("BmxPlayback.Type = %q, want %q", navItem.Links.BmxPlayback.Type, tc.wantType)
|
||||
}
|
||||
|
||||
if !strings.Contains(navItem.Links.BmxPlayback.Href, tc.guideID) {
|
||||
t.Errorf("BmxPlayback.Href must carry the guide-id %q; got %q", tc.guideID, navItem.Links.BmxPlayback.Href)
|
||||
}
|
||||
|
||||
if !strings.Contains(navItem.Links.BmxPlayback.Href, "encoded_name=") {
|
||||
t.Errorf("BmxPlayback.Href should carry encoded_name; got %q", navItem.Links.BmxPlayback.Href)
|
||||
}
|
||||
} else if navItem.Links.BmxPlayback != nil {
|
||||
t.Errorf("did not expect BmxPlayback link; got %+v", navItem.Links.BmxPlayback)
|
||||
}
|
||||
|
||||
// Navigation drill-in must always remain available, even when
|
||||
// a play button is emitted — clicking the card body should
|
||||
// still take the user to the episode list.
|
||||
if navItem.Links.BmxNavigate == nil {
|
||||
t.Error("expected BmxNavigate link to remain available")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTuneInProgramContents pins the contract behind the
|
||||
// p<N> → t<N> expansion that powers `--program` playback for issue
|
||||
// #226. Real-world fixture shape captured from
|
||||
// api.tunein.com/profiles/p290778/contents (see
|
||||
// `_/i226/tunein-probe/profile_contents.json`).
|
||||
func TestParseTuneInProgramContents(t *testing.T) {
|
||||
const happyPath = `{
|
||||
"Items": [
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Episodes",
|
||||
"Children": [
|
||||
{ "GuideId": "t554138374", "Type": "Topic", "Title": "newest" },
|
||||
{ "GuideId": "t554134863", "Type": "Topic", "Title": "previous" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// TuneIn varies the localised container title; verify the
|
||||
// fallback picks the first Topics container even when the title
|
||||
// doesn't match "Episodes".
|
||||
const localisedTitle = `{
|
||||
"Items": [
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Folgen",
|
||||
"Children": [
|
||||
{ "GuideId": "t111", "Type": "Topic", "Title": "newest" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// "Episodes" container precedence: even if a "Related Shows"
|
||||
// Topics container appears first, we must pick the named one.
|
||||
const episodesAfterRelated = `{
|
||||
"Items": [
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Related Shows",
|
||||
"Children": [
|
||||
{ "GuideId": "t999", "Type": "Topic", "Title": "wrong" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Episodes",
|
||||
"Children": [
|
||||
{ "GuideId": "t222", "Type": "Topic", "Title": "right" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// Skip non-topic children — TuneIn occasionally mixes in
|
||||
// container-style children (rare, but defensive).
|
||||
const skipsNonTopic = `{
|
||||
"Items": [
|
||||
{
|
||||
"ContainerType": "Topics",
|
||||
"Title": "Episodes",
|
||||
"Children": [
|
||||
{ "GuideId": "p333", "Type": "Container", "Title": "nested program" },
|
||||
{ "GuideId": "t444", "Type": "Topic", "Title": "real episode" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantID string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "happy path — first child wins", body: happyPath, wantID: "t554138374"},
|
||||
{name: "localised title — falls back to first Topics container", body: localisedTitle, wantID: "t111"},
|
||||
{name: "Episodes container preferred over Related", body: episodesAfterRelated, wantID: "t222"},
|
||||
{name: "skips non-Topic children", body: skipsNonTopic, wantID: "t444"},
|
||||
{name: "empty body — error", body: `{}`, wantError: true},
|
||||
{name: "no Topics containers — error", body: `{"Items":[{"ContainerType":"Banner","Children":[]}]}`, wantError: true},
|
||||
{name: "Topics with no t-prefixed children — error",
|
||||
body: `{"Items":[{"ContainerType":"Topics","Title":"Episodes","Children":[{"GuideId":"p1"}]}]}`,
|
||||
wantError: true},
|
||||
{name: "malformed JSON — error", body: `{not json`, wantError: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseTuneInProgramContents([]byte(tc.body), "p290778")
|
||||
|
||||
if tc.wantError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got id=%q", got)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tc.wantID {
|
||||
t.Errorf("got episode id %q, want %q", got, tc.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,23 @@
|
||||
// Package handlers provides HTTP handlers for the SoundTouch service.
|
||||
// Package handlers — BMX registry / availability and shared helpers.
|
||||
//
|
||||
// Per-service handlers live in handlers_bmx_<service>.go:
|
||||
// - handlers_bmx_tunein.go (TuneIn — playback / podcasts / navigate / search / favorites / report)
|
||||
// - handlers_bmx_orion.go (Orion — LOCAL_INTERNET_RADIO token + station)
|
||||
// - handlers_bmx_custom.go (our own custom-playback adapter)
|
||||
//
|
||||
// The split happened on 2026-05-17 as a pure refactor — no logic change.
|
||||
// A future iteration may extract a common BMX-service interface (see
|
||||
// memory project_bmx_service_interface.md) once enough services are
|
||||
// fully implemented to make the common shape observable.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// tuneInStreamFormats returns the formats= list AfterTouch should send
|
||||
// to TuneIn's Tune.ashx, honouring Settings.TuneInStreamFormats when
|
||||
// set. Empty (the default) lets bmx.TuneInStream fall back to
|
||||
// bmx.DefaultTuneInStreamFormats — the SoundTouch-line-compatible
|
||||
// "mp3,aac,ogg" shape. Operators with HLS-capable speakers can set
|
||||
// the field to "mp3,aac,ogg,hls" (or any other comma-separated list)
|
||||
// in settings.json.
|
||||
func (s *Server) tuneInStreamFormats() string {
|
||||
if s == nil || s.ds == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
settings, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return settings.TuneInStreamFormats
|
||||
}
|
||||
|
||||
// HandleBMXRegistry returns the BMX service registry.
|
||||
func (s *Server) HandleBMXRegistry(w http.ResponseWriter, _ *http.Request) {
|
||||
baseURL := s.serverURL
|
||||
@@ -62,6 +45,67 @@ func (s *Server) HandleBMXServicesAvailability(w http.ResponseWriter, _ *http.Re
|
||||
_, _ = w.Write(bmxServicesAvailabilityJSON)
|
||||
}
|
||||
|
||||
// extractBMXService finds a single service entry in bmx_services.json by
|
||||
// its `id.name` (e.g. "SIRIUSXM_EVEREST", "TUNEIN"). Returns the raw JSON
|
||||
// segment for that service so callers can apply {BMX_SERVER} / {MEDIA_SERVER}
|
||||
// substitution and write it back to the wire.
|
||||
func extractBMXService(bmxJSON []byte, name string) (json.RawMessage, error) {
|
||||
var wrapper struct {
|
||||
BMXServices []json.RawMessage `json:"bmx_services"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bmxJSON, &wrapper); err != nil {
|
||||
return nil, fmt.Errorf("parse bmx_services.json: %w", err)
|
||||
}
|
||||
|
||||
for _, raw := range wrapper.BMXServices {
|
||||
var idOnly struct {
|
||||
ID struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"id"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(raw, &idOnly); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if idOnly.ID.Name == name {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("service %q not found in bmx_services.json", name)
|
||||
}
|
||||
|
||||
// applyBMXTemplate runs the same {BMX_SERVER} / {MEDIA_SERVER} substitution
|
||||
// HandleBMXRegistry uses, so service-descriptor responses produced from
|
||||
// sub-segments of bmx_services.json land at the same hostnames the
|
||||
// registry advertises.
|
||||
func (s *Server) applyBMXTemplate(content string) string {
|
||||
baseURL := s.serverURL
|
||||
|
||||
s.mu.RLock()
|
||||
dnsEnabled := s.dnsEnabled
|
||||
s.mu.RUnlock()
|
||||
|
||||
bmxServer := baseURL
|
||||
if dnsEnabled {
|
||||
bmxServer = "https://content.api.bose.io"
|
||||
}
|
||||
|
||||
content = strings.ReplaceAll(content, "{BMX_SERVER}", bmxServer)
|
||||
content = strings.ReplaceAll(content, "{MEDIA_SERVER}", baseURL+"/media")
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// writeBMXUnauthorized writes the canonical 401 used by every BMX adapter
|
||||
// handler that requires an Authorization header (TuneIn variants, Orion
|
||||
// playback). Currently unused because all gate sites are temporarily
|
||||
// disabled (log-only); kept as the future-restore point — when we re-add
|
||||
// the gate, callers will use this helper.
|
||||
//
|
||||
//nolint:unused // intentional: future-restore point for the disabled auth gate.
|
||||
func (s *Server) writeBMXUnauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
@@ -72,355 +116,3 @@ func (s *Server) writeBMXUnauthorized(w http.ResponseWriter) {
|
||||
<p>Authorization not set. No access token found.</p>
|
||||
`))
|
||||
}
|
||||
|
||||
// HandleTuneInPlayback returns TuneIn playback information.
|
||||
func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
|
||||
resp, err := bmx.TuneInPlayback(stationID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInPodcastInfo returns TuneIn podcast information.
|
||||
func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
encodedName := r.URL.Query().Get("encoded_name")
|
||||
|
||||
resp, err := bmx.TuneInPodcastInfo(podcastID, encodedName)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInPlaybackPodcast returns TuneIn podcast playback information.
|
||||
func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
|
||||
resp, err := bmx.TuneInPlaybackPodcast(podcastID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInToken returns a TuneIn access token.
|
||||
func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// For now, we return the provided refresh_token as access_token and refresh_token,
|
||||
// mirroring the behavior seen in the recordings.
|
||||
resp := map[string]string{
|
||||
"access_token": req.RefreshToken,
|
||||
"refresh_token": req.RefreshToken,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOrionToken returns an anonymous Orion access token.
|
||||
// The token is a base64-encoded JSON serial, matching the pattern used by the real Bose BMX Orion service.
|
||||
func (s *Server) HandleOrionToken(w http.ResponseWriter, _ *http.Request) {
|
||||
token := datastore.GenerateSerialSecret("orion")
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"_embedded": map[string]interface{}{
|
||||
"bmx_account": map[string]string{
|
||||
"displayName": "",
|
||||
"username": "",
|
||||
},
|
||||
},
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOrionPlayback returns Orion playback information for the
|
||||
// /core02/svc-bmx-adapter-orion/prod/orion/station?data=... endpoint
|
||||
// the speaker reaches by following its stored LOCAL_INTERNET_RADIO
|
||||
// preset's `location` attribute. The `data` query string is the
|
||||
// base64-encoded JSON blob (streamUrl/imageUrl/name) that the speaker
|
||||
// constructed when the preset was first saved; we just decode and
|
||||
// rewrap it into the Bose BmxPlaybackResponse shape via
|
||||
// bmx.PlayCustomStream.
|
||||
//
|
||||
// Requires a Bearer token in the `Authorization` header — same as
|
||||
// the rest of the BMX playback surface (TuneIn variants and the
|
||||
// orion token endpoint). Real speakers obtain the token via
|
||||
// POST /core02/svc-bmx-adapter-orion/prod/orion/token (HandleOrionToken)
|
||||
// before they ever follow a LOCAL_INTERNET_RADIO preset, so this
|
||||
// check shouldn't cost any legitimate caller.
|
||||
func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
data := r.URL.Query().Get("data")
|
||||
|
||||
resp, err := bmx.PlayCustomStream(data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCustomPlayback returns custom playback information for a given stream URL.
|
||||
func (s *Server) HandleCustomPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
encodedURL := chi.URLParam(r, "encodedURL")
|
||||
imageUrl := r.URL.Query().Get("imageUrl")
|
||||
name := r.URL.Query().Get("name")
|
||||
|
||||
// Decode URL if it's base64 encoded
|
||||
var streamUrl string
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(encodedURL)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(encodedURL)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
streamUrl = string(decoded)
|
||||
} else {
|
||||
// Try unescaping if it's not base64
|
||||
streamUrl, err = url.PathUnescape(encodedURL)
|
||||
if err != nil {
|
||||
streamUrl = encodedURL
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := bmx.BuildCustomStreamResponse(streamUrl, imageUrl, name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInReport handles TuneIn playback reporting.
|
||||
func (s *Server) HandleTuneInReport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
EventType string `json:"eventType"`
|
||||
}
|
||||
|
||||
// We don't strictly need the body to determine the response,
|
||||
// but we decode it to see the eventType.
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if req.EventType == "START" {
|
||||
// Mirroring the response from 0196-20260329-233306.072-POST.http
|
||||
resp := map[string]interface{}{
|
||||
"_links": map[string]interface{}{
|
||||
"self": map[string]interface{}{
|
||||
"href": "/v1/report?" + r.URL.RawQuery,
|
||||
},
|
||||
},
|
||||
"nextReportIn": 1800,
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// For STOP and other events, return an empty object
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate returns live TuneIn navigation results.
|
||||
// Path variants handled via chi wildcard:
|
||||
// - (empty) → top-level browse
|
||||
// - {encodedURI} → browse the given TuneIn URI
|
||||
// - sub/{n}/{encodedURI} → single subsection of a browse page
|
||||
// - profiles/{type}/{id}/{encodedURI} → artist/program profile page
|
||||
func (s *Server) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
wildcard := chi.URLParam(r, "*")
|
||||
|
||||
resp, err := parseTuneInNavigatePath(wildcard)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func parseTuneInNavigatePath(wildcard string) (interface{}, error) {
|
||||
if wildcard == "" {
|
||||
return bmx.TuneInNavigate("", nil)
|
||||
}
|
||||
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
prefix := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch prefix {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
return bmx.TuneInNavigate(rest, nil)
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(rest[:secondSlash])
|
||||
if err != nil {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
return bmx.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
|
||||
case "profiles":
|
||||
// profiles/{type}/{id}/{encodedURI}
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
return bmx.TuneInNavigateProfile(parts[2])
|
||||
|
||||
default:
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch returns live TuneIn search results for the given query.
|
||||
func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
s.writeBMXUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
http.Error(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmx.TuneInSearch(query)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInFavorite handles POST /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
if err := s.ds.SaveTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to persist TuneIn favorite %s: %v", stationID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
// HandleTuneInDeleteFavorite handles DELETE /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInDeleteFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
if err := s.ds.DeleteTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to delete TuneIn favorite %s: %v", stationID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package handlers — AfterTouch's own custom-playback adapter (not a
|
||||
// Bose-official BMX service). Reached via /custom/v1/playback/{encodedURL}
|
||||
// by speakers that follow our LOCAL_INTERNET_RADIO preset locations.
|
||||
//
|
||||
// Split out of handlers_bmx.go on 2026-05-17; pure file move, no logic
|
||||
// change.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// HandleCustomPlayback returns custom playback information for a given stream URL.
|
||||
func (s *Server) HandleCustomPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
encodedURL := chi.URLParam(r, "encodedURL")
|
||||
imageUrl := r.URL.Query().Get("imageUrl")
|
||||
name := r.URL.Query().Get("name")
|
||||
|
||||
// Decode URL if it's base64 encoded
|
||||
var streamUrl string
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(encodedURL)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(encodedURL)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
streamUrl = string(decoded)
|
||||
} else {
|
||||
// Try unescaping if it's not base64
|
||||
streamUrl, err = url.PathUnescape(encodedURL)
|
||||
if err != nil {
|
||||
streamUrl = encodedURL
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := bmx.BuildCustomStreamResponse(streamUrl, imageUrl, name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Package handlers — Orion BMX adapter handlers (LOCAL_INTERNET_RADIO).
|
||||
//
|
||||
// Split out of handlers_bmx.go on 2026-05-17; pure file move, no logic
|
||||
// change. Shared helpers (writeBMXUnauthorized) still live in
|
||||
// handlers_bmx.go.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// HandleOrionToken returns an anonymous Orion access token.
|
||||
// The token is a base64-encoded JSON serial, matching the pattern used by the real Bose BMX Orion service.
|
||||
func (s *Server) HandleOrionToken(w http.ResponseWriter, _ *http.Request) {
|
||||
token := datastore.GenerateSerialSecret("orion")
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"_embedded": map[string]interface{}{
|
||||
"bmx_account": map[string]string{
|
||||
"displayName": "",
|
||||
"username": "",
|
||||
},
|
||||
},
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOrionPlayback returns Orion playback information for the
|
||||
// /core02/svc-bmx-adapter-orion/prod/orion/station?data=... endpoint
|
||||
// the speaker reaches by following its stored LOCAL_INTERNET_RADIO
|
||||
// preset's `location` attribute. The `data` query string is the
|
||||
// base64-encoded JSON blob (streamUrl/imageUrl/name) that the speaker
|
||||
// constructed when the preset was first saved; we just decode and
|
||||
// rewrap it into the Bose BmxPlaybackResponse shape via
|
||||
// bmx.PlayCustomStream.
|
||||
//
|
||||
// Requires a Bearer token in the `Authorization` header — same as
|
||||
// the rest of the BMX playback surface (TuneIn variants and the
|
||||
// orion token endpoint). Real speakers obtain the token via
|
||||
// POST /core02/svc-bmx-adapter-orion/prod/orion/token (HandleOrionToken)
|
||||
// before they ever follow a LOCAL_INTERNET_RADIO preset, so this
|
||||
// check shouldn't cost any legitimate caller.
|
||||
func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// See HandleTuneInPlayback for the rationale. Logged so we can spot
|
||||
// callers that would have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
data := r.URL.Query().Get("data")
|
||||
|
||||
resp, err := bmx.PlayCustomStream(data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,8 @@ func TestHandleTuneInReport(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Unauthorized", func(t *testing.T) {
|
||||
t.Skip("auth gate temporarily disabled in handlers_bmx_tunein.go; restore this assertion when the gate is re-enabled")
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/bmx/tunein/v1/report", nil)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package handlers — SiriusXM BMX adapter (logging stub).
|
||||
//
|
||||
// bmx_services.json declares SIRIUSXM_EVEREST at
|
||||
// `{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`,
|
||||
// and bmx_services_availability.json lists it as available — so speakers
|
||||
// that try SiriusXM hit this path. The bare URL returns the service
|
||||
// descriptor; sub-paths advertised by the descriptor's _links
|
||||
// (/availability, /token, /navigate, /logout, plus the playback paths
|
||||
// the speaker discovers via navigate) currently log + 404 so we have
|
||||
// visibility into real speaker calls for the next implementation pass.
|
||||
//
|
||||
// Reference: deborahgu/soundcork main.py:805 takes the same shape —
|
||||
// returns the SiriusXM service descriptor from the BMX services array
|
||||
// (hardcoded index 2). We select by id.name instead of array index.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleSiriusXMLiveAdapter returns the SIRIUSXM_EVEREST service descriptor
|
||||
// from bmx_services.json for the bare live-adapter base URL.
|
||||
//
|
||||
// NB: we log the *presence* of the Authorization header, not its value —
|
||||
// the header carries a long-lived bearer token (margeAuthToken) that
|
||||
// would be replayable if a logfile got captured. CodeQL
|
||||
// go/clear-text-logging caught the original `auth=%q` shape.
|
||||
func (s *Server) HandleSiriusXMLiveAdapter(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[BMX SiriusXM] %s %s ua=%q authPresent=%t query=%q",
|
||||
r.Method, r.URL.Path, r.UserAgent(),
|
||||
r.Header.Get("Authorization") != "", r.URL.RawQuery)
|
||||
|
||||
svc, err := extractBMXService(bmxServicesJSON, "SIRIUSXM_EVEREST")
|
||||
if err != nil {
|
||||
log.Printf("[BMX SiriusXM] failed to extract service descriptor: %v", err)
|
||||
http.Error(w, "service descriptor unavailable", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body := s.applyBMXTemplate(string(svc))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
|
||||
// HandleSiriusXMLiveAdapterSubpath logs and 404s any unimplemented sub-path
|
||||
// under the SiriusXM live-adapter. Visibility for the next implementation
|
||||
// pass — the _links in the descriptor publish /availability, /token,
|
||||
// /navigate, /logout; playback URLs come dynamically from navigate.
|
||||
func (s *Server) HandleSiriusXMLiveAdapterSubpath(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[BMX SiriusXM] UNIMPLEMENTED %s %s ua=%q authPresent=%t query=%q",
|
||||
r.Method, r.URL.Path, r.UserAgent(),
|
||||
r.Header.Get("Authorization") != "", r.URL.RawQuery)
|
||||
|
||||
http.Error(w, "not implemented", http.StatusNotFound)
|
||||
}
|
||||
@@ -159,6 +159,8 @@ func TestCustomPlayback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBMXUnauthorized(t *testing.T) {
|
||||
t.Skip("auth gate temporarily disabled in handlers_bmx_tunein.go and handlers_bmx_orion.go; restore this assertion when the gate is re-enabled")
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// Package handlers — TuneIn BMX adapter handlers.
|
||||
//
|
||||
// Split out of handlers_bmx.go on 2026-05-17; pure file move, no logic
|
||||
// change. Shared helpers (writeBMXUnauthorized, bmxServicesJSON) still
|
||||
// live in handlers_bmx.go.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// tuneInStreamFormats returns the formats= list AfterTouch should send
|
||||
// to TuneIn's Tune.ashx, honouring Settings.TuneInStreamFormats when
|
||||
// set. Empty (the default) lets bmx.TuneInStream fall back to
|
||||
// bmx.DefaultTuneInStreamFormats — the SoundTouch-line-compatible
|
||||
// "mp3,aac,ogg" shape. Operators with HLS-capable speakers can set
|
||||
// the field to "mp3,aac,ogg,hls" (or any other comma-separated list)
|
||||
// in settings.json.
|
||||
func (s *Server) tuneInStreamFormats() string {
|
||||
if s == nil || s.ds == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
settings, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return settings.TuneInStreamFormats
|
||||
}
|
||||
|
||||
// HandleTuneInPlayback returns TuneIn playback information.
|
||||
func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
// that target our own service. Logged so we can spot callers that would
|
||||
// have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
|
||||
resp, err := bmx.TuneInPlayback(stationID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInPodcastInfo returns TuneIn podcast information.
|
||||
func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
// that target our own service. Logged so we can spot callers that would
|
||||
// have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
encodedName := r.URL.Query().Get("encoded_name")
|
||||
|
||||
resp, err := bmx.TuneInPodcastInfo(podcastID, encodedName)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInPlaybackPodcast returns TuneIn podcast playback information.
|
||||
func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
// that target our own service. Logged so we can spot callers that would
|
||||
// have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
|
||||
resp, err := bmx.TuneInPlaybackPodcast(podcastID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInToken returns a TuneIn access token.
|
||||
func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// For now, we return the provided refresh_token as access_token and refresh_token,
|
||||
// mirroring the behavior seen in the recordings.
|
||||
resp := map[string]string{
|
||||
"access_token": req.RefreshToken,
|
||||
"refresh_token": req.RefreshToken,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInReport handles TuneIn playback reporting.
|
||||
func (s *Server) HandleTuneInReport(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
// that target our own service. Logged so we can spot callers that would
|
||||
// have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
var req struct {
|
||||
EventType string `json:"eventType"`
|
||||
}
|
||||
|
||||
// We don't strictly need the body to determine the response,
|
||||
// but we decode it to see the eventType.
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if req.EventType == "START" {
|
||||
// Mirroring the response from 0196-20260329-233306.072-POST.http
|
||||
resp := map[string]interface{}{
|
||||
"_links": map[string]interface{}{
|
||||
"self": map[string]interface{}{
|
||||
"href": "/v1/report?" + r.URL.RawQuery,
|
||||
},
|
||||
},
|
||||
"nextReportIn": 1800,
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// For STOP and other events, return an empty object
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate returns live TuneIn navigation results.
|
||||
// Path variants handled via chi wildcard:
|
||||
// - (empty) → top-level browse
|
||||
// - {encodedURI} → browse the given TuneIn URI
|
||||
// - sub/{n}/{encodedURI} → single subsection of a browse page
|
||||
// - profiles/{type}/{id}/{encodedURI} → artist/program profile page
|
||||
func (s *Server) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
// that target our own service. Logged so we can spot callers that would
|
||||
// have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
wildcard := chi.URLParam(r, "*")
|
||||
|
||||
resp, err := parseTuneInNavigatePath(wildcard)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func parseTuneInNavigatePath(wildcard string) (interface{}, error) {
|
||||
if wildcard == "" {
|
||||
return bmx.TuneInNavigate("", nil)
|
||||
}
|
||||
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
prefix := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch prefix {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
return bmx.TuneInNavigate(rest, nil)
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(rest[:secondSlash])
|
||||
if err != nil {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
return bmx.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
|
||||
case "profiles":
|
||||
// profiles/{type}/{id}/{encodedURI}
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
|
||||
return bmx.TuneInNavigateProfile(parts[2])
|
||||
|
||||
default:
|
||||
return bmx.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch returns live TuneIn search results for the given query.
|
||||
func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// Authorization gate temporarily disabled (was: 401 if header missing).
|
||||
// The Stockholm browser proxy doesn't inject Authorization for requests
|
||||
// that target our own service. Logged so we can spot callers that would
|
||||
// have been rejected; do NOT 401.
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
|
||||
r.URL.Path, r.UserAgent())
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
http.Error(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmx.TuneInSearch(query)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInFavorite handles POST /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
if err := s.ds.SaveTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to persist TuneIn favorite %s: %v", stationID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
// HandleTuneInDeleteFavorite handles DELETE /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInDeleteFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
if err := s.ds.DeleteTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to delete TuneIn favorite %s: %v", stationID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}
|
||||
@@ -47,6 +47,8 @@ func TestHandleTuneInNavigate(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Unauthorized", func(t *testing.T) {
|
||||
t.Skip("auth gate temporarily disabled in handlers_bmx_tunein.go; restore this assertion when the gate is re-enabled")
|
||||
|
||||
req := httptest.NewRequest("GET", "/bmx/tunein/v1/navigate", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -83,6 +85,8 @@ func TestHandleTuneInSearch(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Unauthorized", func(t *testing.T) {
|
||||
t.Skip("auth gate temporarily disabled in handlers_bmx_tunein.go; restore this assertion when the gate is re-enabled")
|
||||
|
||||
req := httptest.NewRequest("GET", "/bmx/tunein/v1/search?q=music", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
)
|
||||
|
||||
// TestPrimeDeviceWithSpotify_RegistersMargeSource is a regression test for the
|
||||
// "AddPreset - failed due to invalid SourceID" failure observed when storing a
|
||||
// Spotify preset on a primed device. The watchdog priming path used to push
|
||||
// ZeroConf credentials without writing a SPOTIFY ConfiguredSource into the
|
||||
// marge datastore — so marge.UpdatePreset later had nothing to match
|
||||
// SourceID="SPOTIFY" against and rejected the storePreset request.
|
||||
//
|
||||
// This test verifies that PrimeDeviceWithSpotify now also calls marge.AddSource
|
||||
// for the device's account, producing a ConfiguredSource with
|
||||
// SourceProviderID="15" (constants.SpotifyProviderID).
|
||||
func TestPrimeDeviceWithSpotify_RegistersMargeSource(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
// Fake speaker that accepts the ZeroConf push via the simplified
|
||||
// (non-DH) fallback AND records whether /notification (sourcesUpdated)
|
||||
// was hit.
|
||||
var notified atomic.Bool
|
||||
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/notification" {
|
||||
notified.Store(true)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8" ?><status>/notification</status>`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
speakerHostPort := strings.TrimPrefix(speakerTS.URL, "http://")
|
||||
|
||||
speakerHost, _, err := net.SplitHostPort(speakerHostPort)
|
||||
if err != nil {
|
||||
t.Fatalf("split speaker URL: %v", err)
|
||||
}
|
||||
|
||||
// Register the device under a real account so the IP→account lookup succeeds.
|
||||
const accountID = "acc-prime"
|
||||
const deviceID = "DEVPRIME"
|
||||
devInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Test Speaker",
|
||||
IPAddress: speakerHost,
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, devInfo); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
// marge.AddSource walks the account/devices dir — make sure the per-device
|
||||
// subdir exists so the source actually gets persisted.
|
||||
if err := os.MkdirAll(filepath.Join(ds.AccountDevicesDir(accountID), deviceID), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll device dir: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed a linked Spotify account so PrimeDeviceWithSpotify has something
|
||||
// to push. The token is valid for an hour so GetFreshToken won't try to
|
||||
// refresh against a live endpoint. We point the token endpoint at a noop
|
||||
// URL just in case, so a stray refresh would fail loudly rather than fan
|
||||
// out to the internet.
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
if err := os.MkdirAll(spotifyDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll spotify dir: %v", err)
|
||||
}
|
||||
|
||||
accountsPayload := map[string]map[string]any{
|
||||
"spotify-user": {
|
||||
"user_id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"email": "user@example.com",
|
||||
"access_token": "fresh-access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
"bose_secret": "bs-deadbeef",
|
||||
},
|
||||
}
|
||||
accountsJSON, err := json.Marshal(accountsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal accounts: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), accountsJSON, 0o600); err != nil {
|
||||
t.Fatalf("write accounts.json: %v", err)
|
||||
}
|
||||
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
// Unused fallback token endpoint — defensive in case the test ever drifts
|
||||
// to an expired token.
|
||||
ss.SetEndpoints("http://127.0.0.1:1/token", "http://127.0.0.1:1")
|
||||
|
||||
if err := ss.Load(); err != nil {
|
||||
t.Fatalf("Load spotify accounts: %v", err)
|
||||
}
|
||||
|
||||
if len(ss.GetAccounts()) != 1 {
|
||||
t.Fatalf("expected 1 spotify account after Load, got %d", len(ss.GetAccounts()))
|
||||
}
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
// Sanity: no SPOTIFY source registered yet.
|
||||
sources, _ := ds.GetConfiguredSources(accountID, deviceID)
|
||||
if hasSpotifySource(sources) {
|
||||
t.Fatalf("precondition failed: SPOTIFY source already present before priming")
|
||||
}
|
||||
|
||||
// Pass host:port so the ZeroConf push hits our test server instead of the
|
||||
// hard-coded :8200 fallback. The IP→account lookup strips the port before
|
||||
// matching against devInfo.IPAddress.
|
||||
server.PrimeDeviceWithSpotify(speakerHostPort)
|
||||
|
||||
sources, err = ds.GetConfiguredSources(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources after priming: %v", err)
|
||||
}
|
||||
|
||||
if !hasSpotifySource(sources) {
|
||||
for _, src := range sources {
|
||||
t.Logf("source after priming: ID=%s providerID=%s keyType=%s account=%s", src.ID, src.SourceProviderID, src.SourceKey.Type, src.SourceKey.Account)
|
||||
}
|
||||
|
||||
t.Fatalf("expected a SPOTIFY ConfiguredSource (providerID=%d) after priming", constants.SpotifyProviderID)
|
||||
}
|
||||
|
||||
// The speaker's on-device Sources.xml only refreshes when we tell it to —
|
||||
// without this notification storePreset keeps failing even though marge
|
||||
// already has the SPOTIFY source.
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && !notified.Load() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
if !notified.Load() {
|
||||
t.Errorf("speaker did not receive a sourcesUpdated /notification after priming")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrimeDeviceWithSpotify_SkipsWhenDeviceUnmapped ensures that priming a
|
||||
// device whose IP is not associated with any account does NOT fabricate a
|
||||
// source under the "default" account — the previous behavior would silently
|
||||
// pollute marge with sources for devices that never asked.
|
||||
func TestPrimeDeviceWithSpotify_SkipsWhenDeviceUnmapped(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
speakerURL, _ := url.Parse(speakerTS.URL)
|
||||
speakerHostPort := speakerURL.Host
|
||||
|
||||
// Pre-seed a Spotify account but do NOT register any device.
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
_ = os.MkdirAll(spotifyDir, 0o755)
|
||||
accountsPayload := map[string]map[string]any{
|
||||
"spotify-user": {
|
||||
"user_id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"access_token": "fresh-access-token",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
"bose_secret": "bs-deadbeef",
|
||||
},
|
||||
}
|
||||
|
||||
accountsJSON, err := json.Marshal(accountsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal accounts: %v", err)
|
||||
}
|
||||
|
||||
_ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), accountsJSON, 0o600)
|
||||
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
ss.SetEndpoints("http://127.0.0.1:1/token", "http://127.0.0.1:1")
|
||||
|
||||
if err := ss.Load(); err != nil {
|
||||
t.Fatalf("Load spotify accounts: %v", err)
|
||||
}
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
server.PrimeDeviceWithSpotify(speakerHostPort)
|
||||
|
||||
// "default" account should have no SPOTIFY source added by us.
|
||||
sources, _ := ds.GetConfiguredSources("default", "")
|
||||
if hasSpotifySource(sources) {
|
||||
t.Errorf("priming an unmapped device wrote a SPOTIFY source under 'default' — should have been skipped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrimeDeviceWithSpotify_LiveMargeAccountUUIDWins covers the production
|
||||
// scenario the previous test didn't catch: a device whose datastore
|
||||
// ServiceDeviceInfo.AccountID is "default" (or stale) but whose live
|
||||
// :8090/info reports a real paired margeAccountUUID. The SPOTIFY source must
|
||||
// land under the paired account — that's the account marge.UpdatePreset
|
||||
// receives storePreset under, so writing anywhere else means the preset still
|
||||
// fails with "AddPreset - failed due to invalid SourceID".
|
||||
//
|
||||
// Mirrors setup.populateDeviceInfo's resolution order (datastore ← live /info)
|
||||
// rather than guessing.
|
||||
func TestPrimeDeviceWithSpotify_LiveMargeAccountUUIDWins(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
const (
|
||||
datastoreAccount = "default" // stale / fallback
|
||||
pairedAccount = "1111111" // live margeAccountUUID from /info
|
||||
deviceID = "DEVPAIR"
|
||||
)
|
||||
|
||||
// Fake speaker that serves both /info and the ZeroConf /zc.
|
||||
var speakerHost string
|
||||
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/info"):
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8" ?>`+
|
||||
`<info deviceID="`+deviceID+`">`+
|
||||
`<name>Paired Speaker</name><type>SoundTouch 20</type>`+
|
||||
`<margeAccountUUID>`+pairedAccount+`</margeAccountUUID>`+
|
||||
`</info>`)
|
||||
case r.URL.Path == "/notification":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8" ?><status>/notification</status>`)
|
||||
default:
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
speakerHostPort := strings.TrimPrefix(speakerTS.URL, "http://")
|
||||
speakerHost, _, _ = net.SplitHostPort(speakerHostPort)
|
||||
|
||||
// Register the device under the STALE account so the datastore lookup
|
||||
// would yield the wrong answer if used in isolation.
|
||||
devInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: datastoreAccount,
|
||||
Name: "Paired Speaker",
|
||||
IPAddress: speakerHost,
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(datastoreAccount, deviceID, devInfo); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
// And make sure the paired account's device dir exists so
|
||||
// marge.AddSource can persist the source (it walks accounts/devices/...).
|
||||
if err := os.MkdirAll(filepath.Join(ds.AccountDevicesDir(pairedAccount), deviceID), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll paired dir: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed a Spotify account so priming has something to push.
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
_ = os.MkdirAll(spotifyDir, 0o755)
|
||||
accountsPayload := map[string]map[string]any{
|
||||
"spotify-user": {
|
||||
"user_id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"access_token": "fresh-access-token",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
"bose_secret": "bs-deadbeef",
|
||||
},
|
||||
}
|
||||
|
||||
accountsJSON, err := json.Marshal(accountsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal accounts: %v", err)
|
||||
}
|
||||
|
||||
_ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), accountsJSON, 0o600)
|
||||
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
ss.SetEndpoints("http://127.0.0.1:1/token", "http://127.0.0.1:1")
|
||||
|
||||
if err := ss.Load(); err != nil {
|
||||
t.Fatalf("Load spotify accounts: %v", err)
|
||||
}
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
// Wire a real setup.Manager so resolvePairedAccount reaches /info.
|
||||
// HTTPGet uses the default net/http client, which hits the httptest
|
||||
// server directly via deviceIP=host:port.
|
||||
server.sm = setup.NewManager("http://localhost", ds, nil)
|
||||
|
||||
server.PrimeDeviceWithSpotify(speakerHostPort)
|
||||
|
||||
// SPOTIFY source must be under the PAIRED account, not the datastore one.
|
||||
pairedSources, err := ds.GetConfiguredSources(pairedAccount, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources(paired): %v", err)
|
||||
}
|
||||
|
||||
if !hasSpotifySource(pairedSources) {
|
||||
t.Errorf("expected SPOTIFY source under paired account %s, got %d sources", pairedAccount, len(pairedSources))
|
||||
}
|
||||
|
||||
// And it must NOT have been written under the stale datastore account.
|
||||
staleSources, _ := ds.GetConfiguredSources(datastoreAccount, deviceID)
|
||||
if hasSpotifySource(staleSources) {
|
||||
t.Errorf("SPOTIFY source unexpectedly written under stale datastore account %s — should follow live margeAccountUUID", datastoreAccount)
|
||||
}
|
||||
}
|
||||
|
||||
func hasSpotifySource(sources []models.ConfiguredSource) bool {
|
||||
for _, src := range sources {
|
||||
if src.SourceProviderID == "15" || src.SourceKey.Type == constants.ProviderSpotify {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -3,19 +3,24 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
@@ -659,13 +664,123 @@ func (s *Server) PrimeDeviceWithSpotify(deviceIP string) {
|
||||
|
||||
log.Printf("[Spotify Watchdog] Proactively priming %s with Spotify user %s", deviceIP, username)
|
||||
|
||||
// Register the SPOTIFY source in our marge datastore before pushing credentials.
|
||||
// Without this, storePreset later fails with "AddPreset - failed due to invalid SourceID"
|
||||
// because marge.UpdatePreset can't match SourceID="SPOTIFY" against any ConfiguredSource.
|
||||
s.registerSpotifySourceForDevice(deviceIP, accounts)
|
||||
|
||||
if err := s.pushSpotifyTokenToDevice(deviceIP, username, accessToken); err != nil {
|
||||
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
// addUser may return a benign 404+empty-body no-op when the speaker
|
||||
// already has the activeUser set. The zeroconf-level log already
|
||||
// recorded the specifics; here we just upgrade the watchdog's view to
|
||||
// "primed" since marge holds the authoritative SPOTIFY source.
|
||||
if errors.Is(err, spotify.ErrAddUserNoOp) {
|
||||
log.Printf("[Spotify Watchdog] Successfully primed %s (ZeroConf addUser was an expected no-op)", deviceIP)
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Successfully primed %s", deviceIP)
|
||||
}
|
||||
}
|
||||
|
||||
// registerSpotifySourceForDevice writes a SPOTIFY ConfiguredSource into the marge
|
||||
// datastore under the device's currently-paired account. No-op (with a log
|
||||
// message) if the device can't be resolved to an account — falling back to
|
||||
// "default" here would risk polluting an unrelated account's source list, and
|
||||
// any storePreset the device sends will be under its real paired account anyway.
|
||||
func (s *Server) registerSpotifySourceForDevice(deviceIP string, accounts []spotify.Account) {
|
||||
host := deviceIP
|
||||
if h, _, err := net.SplitHostPort(deviceIP); err == nil {
|
||||
host = h
|
||||
}
|
||||
|
||||
accountID, deviceID := s.resolvePairedAccount(deviceIP, host)
|
||||
if accountID == "" {
|
||||
log.Printf("[Spotify Watchdog] No paired account for %s yet — skipping marge source registration", deviceIP)
|
||||
return
|
||||
}
|
||||
|
||||
registered := false
|
||||
|
||||
for _, acc := range accounts {
|
||||
credential := acc.BoseSecret
|
||||
if credential == "" {
|
||||
credential = acc.AccessToken
|
||||
}
|
||||
|
||||
if _, err := marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.SpotifyProviderID), credential, "token_version_3", acc.DisplayName); err != nil {
|
||||
log.Printf("[Spotify Watchdog] Failed to register Spotify source for account %s: %v", accountID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[Spotify Watchdog] Registered Spotify source %s for account %s (device %s)", acc.UserID, accountID, deviceID)
|
||||
|
||||
registered = true
|
||||
}
|
||||
|
||||
// Tell the speaker its sources list changed so it re-fetches from marge.
|
||||
// Without this its on-device Sources.xml stays stale until something else
|
||||
// triggers a sync — which leaves storePreset failing with
|
||||
// "AddPreset - failed due to invalid SourceID" even though our marge
|
||||
// datastore already has the SPOTIFY entry.
|
||||
if registered && deviceID != "" {
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
if err := c.NotifySourcesUpdated(deviceID); err != nil {
|
||||
log.Printf("[Spotify Watchdog] sourcesUpdated notification for %s failed: %v", deviceIP, err)
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Notified %s to re-sync sources (deviceID=%s)", deviceIP, deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePairedAccount returns the device's currently-paired account ID and its
|
||||
// canonical deviceID. It prefers the live :8090/info margeAccountUUID (matches
|
||||
// what the device will actually send on storePreset) and falls back to the
|
||||
// datastore record. Mirrors setup.populateDeviceInfo's resolution order so
|
||||
// priming and migration agree on which account a device belongs to.
|
||||
//
|
||||
// deviceIP is the original input (may carry a :port for tests); host is the
|
||||
// bare host for datastore IPAddress matching.
|
||||
func (s *Server) resolvePairedAccount(deviceIP, host string) (accountID, deviceID string) {
|
||||
if devInfo := s.findExistingDeviceInfoByIP(host); devInfo != nil {
|
||||
accountID = devInfo.AccountID
|
||||
deviceID = devInfo.DeviceID
|
||||
}
|
||||
|
||||
if s.sm != nil {
|
||||
if info, err := s.sm.GetLiveDeviceInfo(deviceIP); err == nil {
|
||||
if info.MargeAccountUUID != "" {
|
||||
accountID = info.MargeAccountUUID
|
||||
}
|
||||
|
||||
if info.DeviceID != "" {
|
||||
deviceID = info.DeviceID
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] live /info lookup for %s failed: %v (falling back to datastore account=%q)", deviceIP, err, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
return accountID, deviceID
|
||||
}
|
||||
|
||||
// findExistingDeviceInfoByIP looks up a device record by IP address across all accounts.
|
||||
func (s *Server) findExistingDeviceInfoByIP(ip string) *models.ServiceDeviceInfo {
|
||||
allDevices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range allDevices {
|
||||
if allDevices[i].IPAddress == ip {
|
||||
return &allDevices[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string) error {
|
||||
var zcURL string
|
||||
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
|
||||
@@ -701,7 +816,11 @@ func (s *Server) PrimeDeviceWithAmazon(deviceIP string) {
|
||||
log.Printf("[Amazon Watchdog] Proactively priming %s with Amazon user %s", deviceIP, username)
|
||||
|
||||
if err := s.pushAmazonTokenToDevice(deviceIP, username, accessToken); err != nil {
|
||||
log.Printf("[Amazon Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
if errors.Is(err, amazon.ErrAddUserNoOp) {
|
||||
log.Printf("[Amazon Watchdog] Successfully primed %s (ZeroConf addUser was an expected no-op)", deviceIP)
|
||||
} else {
|
||||
log.Printf("[Amazon Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Amazon Watchdog] Successfully primed %s", deviceIP)
|
||||
}
|
||||
|
||||
@@ -2588,7 +2588,12 @@ func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
|
||||
var servicePresets []models.ServicePreset
|
||||
|
||||
for _, p := range ps.Preset {
|
||||
if p.ContentItem == nil {
|
||||
// IsEmpty catches both placeholder shapes a SoundTouch device
|
||||
// can emit: self-closing <preset/> (issue #308) and
|
||||
// <ContentItem source="INVALID_SOURCE"/>. Neither carries
|
||||
// real playable data and persisting them would surface as
|
||||
// junk entries in the admin web UI.
|
||||
if p.IsEmpty() {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ package spotify
|
||||
|
||||
import "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
|
||||
|
||||
// ErrAddUserNoOp re-exports zeroconf.ErrAddUserNoOp so callers in the spotify
|
||||
// package don't need a direct dependency on the zeroconf package to recognise
|
||||
// the benign-no-op sentinel.
|
||||
var ErrAddUserNoOp = zeroconf.ErrAddUserNoOp
|
||||
|
||||
// ZeroConfGetInfo fetches the speaker's DH public key via GET ?action=getInfo.
|
||||
func ZeroConfGetInfo(zcBaseURL string) ([]byte, error) {
|
||||
return zeroconf.GetInfo(zcBaseURL)
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// Package stockholm implements the Stockholm frontend backend: native bridge,
|
||||
// HTTP proxy, static file serving, SSDP discovery, and state persistence.
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bridgeMessage is a single message in the runQueue response.
|
||||
type bridgeMessage struct {
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error interface{} `json:"error,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params interface{} `json:"params,omitempty"`
|
||||
ID interface{} `json:"id"`
|
||||
}
|
||||
|
||||
// appSendRequest is the JSON body of a POST /api/native/appSend call.
|
||||
type appSendRequest struct {
|
||||
Method string `json:"method"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
ID interface{} `json:"id"`
|
||||
}
|
||||
|
||||
// Bridge manages per-clientId message queues for the native bridge.
|
||||
type Bridge struct {
|
||||
cfg *Config
|
||||
state *NativeState
|
||||
queues sync.Map // clientId -> *clientQueue
|
||||
}
|
||||
|
||||
type clientQueue struct {
|
||||
mu sync.Mutex
|
||||
msgs []bridgeMessage
|
||||
}
|
||||
|
||||
func newBridge(cfg *Config, state *NativeState) *Bridge {
|
||||
return &Bridge{cfg: cfg, state: state}
|
||||
}
|
||||
|
||||
// HandleAppSend serves POST /api/native/appSend.
|
||||
func (b *Bridge) HandleAppSend(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := resolveClientID(r)
|
||||
|
||||
var req appSendRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
b.enqueueError(clientID, nil, "invalid_request")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
b.dispatch(clientID, req)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleRunQueue serves GET /api/native/runQueue.
|
||||
func (b *Bridge) HandleRunQueue(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := resolveClientID(r)
|
||||
q := b.getOrCreateQueue(clientID)
|
||||
|
||||
q.mu.Lock()
|
||||
msgs := q.msgs
|
||||
q.msgs = nil
|
||||
q.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
type runQueueResponse struct {
|
||||
Messages []bridgeMessage `json:"messages"`
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(runQueueResponse{Messages: msgs}); err != nil {
|
||||
log.Printf("[Stockholm bridge] Failed to encode runQueue response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) dispatch(clientID string, req appSendRequest) {
|
||||
method := req.Method
|
||||
params := req.Params
|
||||
|
||||
if params == nil {
|
||||
params = map[string]interface{}{}
|
||||
}
|
||||
|
||||
id := req.ID
|
||||
|
||||
log.Printf("[Stockholm bridge] method=%q client=%q", method, clientID)
|
||||
|
||||
switch method {
|
||||
case "locale", "htmlReady", "stopHrmsUpdates":
|
||||
// no-op
|
||||
|
||||
case "log":
|
||||
if msg, _ := params["msg"].(string); msg != "" {
|
||||
log.Printf("[Stockholm:%s] %s", clientID, msg)
|
||||
}
|
||||
|
||||
case "setData":
|
||||
name, _ := params["name"].(string)
|
||||
if name != "" {
|
||||
value := stringifyScalar(params["value"])
|
||||
b.state.Set(name, value)
|
||||
}
|
||||
|
||||
case "getData":
|
||||
name, _ := params["name"].(string)
|
||||
b.enqueueResult(clientID, id, b.state.Get(name), "")
|
||||
|
||||
case "getLanStatus":
|
||||
b.enqueueResult(clientID, id, true, nil)
|
||||
|
||||
case "getTimeZone":
|
||||
b.enqueueResult(clientID, id, map[string]interface{}{
|
||||
"timezoneInfo": localTimezoneName(),
|
||||
"timeFormat": "TIME_FORMAT_24HOUR_ID",
|
||||
}, "")
|
||||
|
||||
case "getLegalDocPath":
|
||||
b.enqueueResult(clientID, id, legalDocPath(params), nil)
|
||||
|
||||
case "getConstant":
|
||||
name, _ := params["name"].(string)
|
||||
val := b.state.Get("constant." + name)
|
||||
|
||||
if val == "" && name == "kilo" {
|
||||
val = kiloDefaultValue
|
||||
}
|
||||
|
||||
b.enqueueResult(clientID, id, val, "")
|
||||
|
||||
case "canPerformAutoAPSetup":
|
||||
b.enqueueResult(clientID, id, map[string]interface{}{
|
||||
"permission": false,
|
||||
"location": false,
|
||||
}, "")
|
||||
|
||||
case "getDeviceList":
|
||||
go b.runDeviceDiscovery(clientID, id)
|
||||
|
||||
case "getHrmsList":
|
||||
go b.runServerDiscovery(clientID, id)
|
||||
|
||||
case "getNetStats", "getSSIDList", "setSSID", "updateSetting", "oauth",
|
||||
"downloadNewGui", "installNewGui", "sendLogs",
|
||||
"socketCreate", "socketSend", "socketClose":
|
||||
b.enqueueError(clientID, id, "unsupported")
|
||||
|
||||
default:
|
||||
b.enqueueError(clientID, id, "unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) runDeviceDiscovery(clientID string, _ interface{}) {
|
||||
expectedAccount := b.state.Get("margeAccountID")
|
||||
|
||||
// The JS "devices" handler reconciles the full list: it removes any device
|
||||
// not present in the latest message. Sending one device at a time would
|
||||
// therefore drop the previous device on each update. Always send the
|
||||
// cumulative list so existing entries are preserved.
|
||||
var seen []RendererDevice
|
||||
|
||||
devices := DiscoverRenderers(expectedAccount, func(d RendererDevice) {
|
||||
seen = append(seen, d)
|
||||
b.enqueueMethod(clientID, "devices", seen)
|
||||
})
|
||||
|
||||
if len(devices) == 0 {
|
||||
b.enqueueMethod(clientID, "devices", []RendererDevice{})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) runServerDiscovery(clientID string, _ interface{}) {
|
||||
servers := DiscoverServers()
|
||||
b.enqueueMethod(clientID, "servers", servers)
|
||||
}
|
||||
|
||||
func (b *Bridge) enqueueResult(clientID string, id, result, errVal interface{}) {
|
||||
b.enqueue(clientID, bridgeMessage{Result: result, Error: errVal, ID: id})
|
||||
}
|
||||
|
||||
func (b *Bridge) enqueueError(clientID string, id interface{}, errMsg string) {
|
||||
b.enqueue(clientID, bridgeMessage{Result: nil, Error: errMsg, ID: id})
|
||||
}
|
||||
|
||||
func (b *Bridge) enqueueMethod(clientID, method string, params interface{}) {
|
||||
b.enqueue(clientID, bridgeMessage{Method: method, Params: params, ID: nil})
|
||||
}
|
||||
|
||||
func (b *Bridge) enqueue(clientID string, msg bridgeMessage) {
|
||||
q := b.getOrCreateQueue(clientID)
|
||||
|
||||
q.mu.Lock()
|
||||
q.msgs = append(q.msgs, msg)
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bridge) getOrCreateQueue(clientID string) *clientQueue {
|
||||
v, _ := b.queues.LoadOrStore(clientID, &clientQueue{})
|
||||
q, _ := v.(*clientQueue)
|
||||
|
||||
if q == nil {
|
||||
q = &clientQueue{}
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func resolveClientID(r *http.Request) string {
|
||||
if v := r.Header.Get("X-Stockholm-Client-Id"); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
if v := r.URL.Query().Get("clientId"); v != "" {
|
||||
decoded, err := url.QueryUnescape(v)
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
return "default"
|
||||
}
|
||||
|
||||
func legalDocPath(params map[string]interface{}) string {
|
||||
typVal, _ := params["type"].(string)
|
||||
lang, _ := params["lang"].(string)
|
||||
|
||||
// "lcns" = third-party platform/GUI licences; the Stockholm zip ships this
|
||||
// as gui_licenses_en.txt (no per-language variants exist).
|
||||
if typVal == "lcns" {
|
||||
return "legal/gui_licenses_en.txt"
|
||||
}
|
||||
|
||||
if typVal == "" || typVal == "eula" {
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
|
||||
return "legal/eula_" + lang + ".txt"
|
||||
}
|
||||
|
||||
// "privacy" and any other types: the Stockholm zip does not include these
|
||||
// files, so fall back to the English EULA.
|
||||
return "legal/eula_en.txt"
|
||||
}
|
||||
|
||||
func localTimezoneName() string {
|
||||
return time.Local.String()
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- resolveClientID ----
|
||||
|
||||
func TestResolveClientID_Header(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Stockholm-Client-Id", "tab-123")
|
||||
|
||||
if got := resolveClientID(r); got != "tab-123" {
|
||||
t.Errorf("expected tab-123, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientID_QueryParam(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/?clientId=browser-abc", nil)
|
||||
|
||||
if got := resolveClientID(r); got != "browser-abc" {
|
||||
t.Errorf("expected browser-abc, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientID_Default(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
if got := resolveClientID(r); got != "default" {
|
||||
t.Errorf("expected default, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientID_HeaderTakesPrecedence(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/?clientId=from-query", nil)
|
||||
r.Header.Set("X-Stockholm-Client-Id", "from-header")
|
||||
|
||||
if got := resolveClientID(r); got != "from-header" {
|
||||
t.Errorf("expected from-header, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- legalDocPath ----
|
||||
|
||||
func TestLegalDocPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
params map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{map[string]interface{}{"type": "lcns"}, "legal/gui_licenses_en.txt"},
|
||||
{map[string]interface{}{}, "legal/eula_en.txt"},
|
||||
{map[string]interface{}{"type": "eula", "lang": "de"}, "legal/eula_de.txt"},
|
||||
{map[string]interface{}{"type": "privacy"}, "legal/eula_en.txt"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := legalDocPath(tc.params); got != tc.want {
|
||||
t.Errorf("legalDocPath(%v) = %q, want %q", tc.params, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Bridge dispatch via HTTP handlers ----
|
||||
|
||||
func newTestBridge(t *testing.T) *Bridge {
|
||||
t.Helper()
|
||||
state := NewNativeState(t.TempDir())
|
||||
return newBridge(&Config{}, state)
|
||||
}
|
||||
|
||||
func appSend(t *testing.T, b *Bridge, method string, params map[string]interface{}, id interface{}) {
|
||||
t.Helper()
|
||||
body := map[string]interface{}{"method": method, "params": params, "id": id}
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal appSend body: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/native/appSend", bytes.NewReader(data))
|
||||
req.Header.Set("X-Stockholm-Client-Id", "test")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.HandleAppSend(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("HandleAppSend returned %d, want 204", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func drainQueue(t *testing.T, b *Bridge) []bridgeMessage {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/native/runQueue", nil)
|
||||
req.Header.Set("X-Stockholm-Client-Id", "test")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.HandleRunQueue(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("HandleRunQueue returned %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []bridgeMessage `json:"messages"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode runQueue response: %v", err)
|
||||
}
|
||||
|
||||
return resp.Messages
|
||||
}
|
||||
|
||||
// TestBridge_DeviceDiscovery_CumulativeList verifies that each "devices" message
|
||||
// sent during incremental discovery includes all previously found devices.
|
||||
// The JS "devices" handler reconciles the full list and would drop earlier
|
||||
// devices if only the latest one were included.
|
||||
func TestBridge_DeviceDiscovery_CumulativeList(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
|
||||
// Simulate what runDeviceDiscovery now does: build a cumulative slice and
|
||||
// enqueue it with every new device.
|
||||
d1 := RendererDevice{UID: "AABBCC112233", IP: "192.168.1.10"}
|
||||
d2 := RendererDevice{UID: "DDEEFF445566", IP: "192.168.1.11"}
|
||||
|
||||
var seen []RendererDevice
|
||||
for _, d := range []RendererDevice{d1, d2} {
|
||||
seen = append(seen, d)
|
||||
b.enqueueMethod("test", "devices", seen)
|
||||
}
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 queued messages, got %d", len(msgs))
|
||||
}
|
||||
|
||||
// First message: only d1
|
||||
firstParams, ok := msgs[0].Params.([]interface{})
|
||||
if !ok || len(firstParams) != 1 {
|
||||
t.Errorf("first message: expected 1-element params, got %v", msgs[0].Params)
|
||||
}
|
||||
|
||||
// Second message: d1 AND d2
|
||||
secondParams, ok := msgs[1].Params.([]interface{})
|
||||
if !ok || len(secondParams) != 2 {
|
||||
t.Errorf("second message: expected 2-element params, got %v", msgs[1].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_Locale_IsNoOp(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "locale", nil, 1)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 0 {
|
||||
t.Errorf("expected no messages for locale, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_GetLanStatus_ReturnsTrue(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "getLanStatus", nil, 42)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
|
||||
if msgs[0].Result != true {
|
||||
t.Errorf("expected result=true, got %v", msgs[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_SetData_Get_RoundTrip(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "setData", map[string]interface{}{"name": "myKey", "value": "hello"}, nil)
|
||||
appSend(t, b, "getData", map[string]interface{}{"name": "myKey"}, 7)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message (for getData), got %d", len(msgs))
|
||||
}
|
||||
|
||||
if msgs[0].Result != "hello" {
|
||||
t.Errorf("expected result=hello, got %v", msgs[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_GetConstant_Kilo_Default(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "getConstant", map[string]interface{}{"name": "kilo"}, 1)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
|
||||
if msgs[0].Result != "a7928d7b43dcd49f0af31e5aeed26458" {
|
||||
t.Errorf("unexpected kilo value: %v", msgs[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_GetTimeZone_ContainsTimezoneInfo(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "getTimeZone", nil, 2)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
|
||||
result, ok := msgs[0].Result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map result, got %T", msgs[0].Result)
|
||||
}
|
||||
|
||||
if _, hasKey := result["timezoneInfo"]; !hasKey {
|
||||
t.Error("expected timezoneInfo key in getTimeZone result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_CanPerformAutoAPSetup_ReturnsFalse(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "canPerformAutoAPSetup", nil, 3)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
|
||||
result, ok := msgs[0].Result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map result, got %T", msgs[0].Result)
|
||||
}
|
||||
|
||||
if result["permission"] != false {
|
||||
t.Errorf("expected permission=false, got %v", result["permission"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_UnsupportedMethod_ReturnsError(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "downloadNewGui", nil, 99)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
|
||||
if msgs[0].Error == nil {
|
||||
t.Error("expected error for unsupported method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_Log_IsNoOp(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "log", map[string]interface{}{"msg": "hello from js"}, nil)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 0 {
|
||||
t.Errorf("expected no queued messages for log, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_GetLegalDocPath(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
appSend(t, b, "getLegalDocPath", map[string]interface{}{"type": "lcns"}, 5)
|
||||
|
||||
msgs := drainQueue(t, b)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(msgs[0].Result.(string), "gui_licenses_en.txt") {
|
||||
t.Errorf("unexpected legal doc path: %v", msgs[0].Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_RunQueue_EmptyWhenNoPendingMessages(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
msgs := drainQueue(t, b)
|
||||
|
||||
if len(msgs) != 0 {
|
||||
t.Errorf("expected empty queue, got %d messages", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_AppSend_WrongMethod_Returns405(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/native/appSend", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.HandleAppSend(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_RunQueue_WrongMethod_Returns405(t *testing.T) {
|
||||
b := newTestBridge(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/native/runQueue", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.HandleRunQueue(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds the parsed stockholm/json/config.json values.
|
||||
type Config struct {
|
||||
AppVersion string
|
||||
ProtocolVersion string
|
||||
StreamingVersion string
|
||||
CustomerVersion string
|
||||
DefaultMargeURL string
|
||||
DefaultUpdateURL string
|
||||
BmxRegistryURL string
|
||||
AuthServiceURL string
|
||||
EncryptedBmxToken string
|
||||
MargeServerKey string
|
||||
MargeServerKeyHeader string
|
||||
// BasePath is an optional URL prefix under which the Stockholm frontend is
|
||||
// served (e.g. "/stockholm"). Empty means served at "/".
|
||||
BasePath string
|
||||
}
|
||||
|
||||
// BackendConfig holds the parsed backend/config/backend-config.json values.
|
||||
type BackendConfig struct {
|
||||
FrontendLoggingLevel int `json:"frontendLoggingLevel"`
|
||||
}
|
||||
|
||||
var versionPrefix = regexp.MustCompile(`^(\d+(?:\.\d+)+)`)
|
||||
|
||||
// LoadConfig reads and parses stockholm/json/config.json from stockholmDir.
|
||||
func LoadConfig(stockholmDir string) (*Config, error) {
|
||||
path := filepath.Join(stockholmDir, "json", "config.json")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config.json: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse config.json: %w", err)
|
||||
}
|
||||
|
||||
appVersions := jsonObject(raw["app_versions"])
|
||||
apiVersions := jsonObject(raw["api_versions"])
|
||||
defaults := jsonObject(raw["default"])
|
||||
|
||||
cfg := &Config{
|
||||
AppVersion: jsonString(appVersions["bose_app"]),
|
||||
ProtocolVersion: jsonString(appVersions["bose_protocol"]),
|
||||
StreamingVersion: firstNonEmpty(jsonString(apiVersions["bose_streaming"]), "1.0"),
|
||||
CustomerVersion: firstNonEmpty(jsonString(apiVersions["bose_customer"]), "1.0"),
|
||||
DefaultMargeURL: normalizeBaseURL(decodeB64(jsonString(defaults["d0"]))),
|
||||
DefaultUpdateURL: normalizeBaseURL(decodeB64(jsonString(defaults["d1"]))),
|
||||
BmxRegistryURL: decodeB64(jsonString(defaults["d3"])),
|
||||
AuthServiceURL: decodeB64(jsonString(defaults["d6"])),
|
||||
EncryptedBmxToken: decodeB64(jsonString(defaults["d7"])),
|
||||
MargeServerKey: decodeB64(jsonString(defaults["d10"])),
|
||||
MargeServerKeyHeader: decodeB64(jsonString(defaults["d13"])),
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RewriteConfigURLs updates the base64-encoded URL fields in stockholm/json/config.json
|
||||
// to point at backendURL. margeURL is used for streaming.bose.com rewrites; if empty it
|
||||
// defaults to backendURL. Set margeURL to backendURL+"/marge" when using soundcork.
|
||||
// authServiceURL is written into d6 (the auth endpoint); if empty it defaults to
|
||||
// backendURL. A trailing slash is always ensured because the JS concatenates paths like
|
||||
// "oauth/account/..." directly onto this value.
|
||||
func RewriteConfigURLs(stockholmDir, backendURL, margeURL, authServiceURL string) error {
|
||||
if margeURL == "" {
|
||||
margeURL = backendURL
|
||||
}
|
||||
|
||||
if authServiceURL == "" {
|
||||
authServiceURL = backendURL
|
||||
}
|
||||
|
||||
// Ensure trailing slash so JS path concatenation (e.g. d6 + "oauth/account/...")
|
||||
// produces a valid URL.
|
||||
if authServiceURL != "" && !strings.HasSuffix(authServiceURL, "/") {
|
||||
authServiceURL += "/"
|
||||
}
|
||||
|
||||
path := filepath.Join(stockholmDir, "json", "config.json")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config.json: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
|
||||
if unmarshalErr := json.Unmarshal(data, &raw); unmarshalErr != nil {
|
||||
return fmt.Errorf("parse config.json: %w", unmarshalErr)
|
||||
}
|
||||
|
||||
defaults := jsonObject(raw["default"])
|
||||
|
||||
// Decode each field, substitute known hostnames, re-encode
|
||||
replacements := map[string]string{
|
||||
"https://streaming.bose.com": margeURL,
|
||||
"https://events.api.bosecm.com": backendURL,
|
||||
"https://content.api.bose.io": backendURL,
|
||||
"https://worldwide.bose.com": backendURL,
|
||||
"https://downloads.bose.com": backendURL,
|
||||
}
|
||||
|
||||
for key, rawVal := range defaults {
|
||||
decoded := decodeB64(jsonString(rawVal))
|
||||
|
||||
for old, newVal := range replacements {
|
||||
decoded = strings.ReplaceAll(decoded, old, newVal)
|
||||
}
|
||||
|
||||
defaults[key] = jsonRawString(base64.StdEncoding.EncodeToString([]byte(decoded)))
|
||||
}
|
||||
|
||||
// d6 = auth service base URL; always overwrite with a full URL so the JS
|
||||
// does not fall back to treating it as a subdomain prefix.
|
||||
defaults["d6"] = jsonRawString(base64.StdEncoding.EncodeToString([]byte(authServiceURL)))
|
||||
|
||||
// Re-serialize defaults back into the raw map
|
||||
encoded, err := json.Marshal(defaults)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal defaults: %w", err)
|
||||
}
|
||||
|
||||
raw["default"] = json.RawMessage(encoded)
|
||||
|
||||
out, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config.json: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(path, append(out, '\n'), 0644)
|
||||
}
|
||||
|
||||
// LoadBackendConfig reads backend/config/backend-config.json from workspaceRoot.
|
||||
// Returns defaults if the file is absent.
|
||||
func LoadBackendConfig(workspaceRoot string) *BackendConfig {
|
||||
path := filepath.Join(workspaceRoot, "backend", "config", "backend-config.json")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return &BackendConfig{}
|
||||
}
|
||||
|
||||
var cfg BackendConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return &BackendConfig{}
|
||||
}
|
||||
|
||||
if cfg.FrontendLoggingLevel < 0 {
|
||||
cfg.FrontendLoggingLevel = 0
|
||||
}
|
||||
|
||||
return &cfg
|
||||
}
|
||||
|
||||
// StreamingMediaType returns the Accept/Content-Type for streaming API calls.
|
||||
func (c *Config) StreamingMediaType() string {
|
||||
return "application/vnd.bose.streaming-v" + c.StreamingVersion + "+xml"
|
||||
}
|
||||
|
||||
// CustomerMediaType returns the Accept/Content-Type for customer API calls.
|
||||
func (c *Config) CustomerMediaType() string {
|
||||
return "application/vnd.bose.customer-v" + c.CustomerVersion + "+xml"
|
||||
}
|
||||
|
||||
// MediaTypeForPath returns the appropriate media type based on path.
|
||||
func (c *Config) MediaTypeForPath(path string) string {
|
||||
p := strings.ToLower(path)
|
||||
if strings.Contains(p, "/customer/") {
|
||||
return c.CustomerMediaType()
|
||||
}
|
||||
|
||||
if strings.Contains(p, "/streaming/") {
|
||||
return c.StreamingMediaType()
|
||||
}
|
||||
|
||||
return "application/xml"
|
||||
}
|
||||
|
||||
// IsBmxTarget returns true if host is a BMX API target.
|
||||
func (c *Config) IsBmxTarget(host string) bool {
|
||||
h := strings.ToLower(host)
|
||||
|
||||
return h == "content.api.bose.io" ||
|
||||
h == "test.content.api.bose.io" ||
|
||||
h == "bose-prod.apigee.net" ||
|
||||
strings.HasSuffix(h, ".apigee.net")
|
||||
}
|
||||
|
||||
// IsMargeTarget returns true if host+path is a Marge streaming/customer endpoint.
|
||||
func (c *Config) IsMargeTarget(host, path string) bool {
|
||||
h := strings.ToLower(host)
|
||||
|
||||
p := strings.ToLower(path)
|
||||
if !strings.Contains(p, "/streaming/") && !strings.Contains(p, "/customer/") {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasSuffix(h, ".bose.com") || strings.HasSuffix(h, ".apigee.net")
|
||||
}
|
||||
|
||||
// ExtractVersionPrefix returns the leading version number (e.g. "27.0" from "27.0.13-xyz").
|
||||
func ExtractVersionPrefix(v string) string {
|
||||
m := versionPrefix.FindStringSubmatch(v)
|
||||
if len(m) >= 2 {
|
||||
return m[1]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ShouldEnableFrontendDebug returns true when logging level > 0.
|
||||
func (b *BackendConfig) ShouldEnableFrontendDebug() bool {
|
||||
return b.FrontendLoggingLevel > 0
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
func decodeB64(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
// Some values may not be base64 (already plain), return as-is
|
||||
return s
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func normalizeBaseURL(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(s, "/") {
|
||||
return s + "/"
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func jsonObject(raw json.RawMessage) map[string]json.RawMessage {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var obj map[string]json.RawMessage
|
||||
|
||||
_ = json.Unmarshal(raw, &obj)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
func jsonString(raw json.RawMessage) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func jsonRawString(s string) json.RawMessage {
|
||||
// json.Marshal on a string never fails
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return json.RawMessage(`""`)
|
||||
}
|
||||
|
||||
return json.RawMessage(b)
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- ExtractVersionPrefix ----
|
||||
|
||||
func TestExtractVersionPrefix(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"27.0.13-release", "27.0.13"},
|
||||
{"27.0", "27.0"},
|
||||
{"1.2.3.4", "1.2.3.4"},
|
||||
{"v27.0", ""},
|
||||
{"", ""},
|
||||
{"release-27.0", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := ExtractVersionPrefix(tc.input); got != tc.want {
|
||||
t.Errorf("ExtractVersionPrefix(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helper functions ----
|
||||
|
||||
func TestFirstNonEmpty(t *testing.T) {
|
||||
if got := firstNonEmpty("", "", "third", "fourth"); got != "third" {
|
||||
t.Errorf("expected third, got %q", got)
|
||||
}
|
||||
|
||||
if got := firstNonEmpty("", ""); got != "" {
|
||||
t.Errorf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBaseURL(t *testing.T) {
|
||||
if got := normalizeBaseURL("http://example.com"); got != "http://example.com/" {
|
||||
t.Errorf("expected trailing slash, got %q", got)
|
||||
}
|
||||
|
||||
if got := normalizeBaseURL("http://example.com/"); got != "http://example.com/" {
|
||||
t.Errorf("expected no double slash, got %q", got)
|
||||
}
|
||||
|
||||
if got := normalizeBaseURL(""); got != "" {
|
||||
t.Errorf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeB64(t *testing.T) {
|
||||
original := "https://streaming.bose.com"
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(original))
|
||||
|
||||
if got := decodeB64(encoded); got != original {
|
||||
t.Errorf("decodeB64 failed: got %q, want %q", got, original)
|
||||
}
|
||||
|
||||
if got := decodeB64(""); got != "" {
|
||||
t.Errorf("expected empty for empty input, got %q", got)
|
||||
}
|
||||
|
||||
// Not valid base64 → returned as-is
|
||||
if got := decodeB64("plain text"); got != "plain text" {
|
||||
t.Errorf("expected plain text returned as-is, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- LoadConfig / RewriteConfigURLs ----
|
||||
|
||||
func makeConfigJSON(t *testing.T, defaults map[string]string) string {
|
||||
t.Helper()
|
||||
|
||||
// Encode each default value as base64
|
||||
encodedDefaults := make(map[string]interface{})
|
||||
for k, v := range defaults {
|
||||
encodedDefaults[k] = base64.StdEncoding.EncodeToString([]byte(v))
|
||||
}
|
||||
|
||||
raw := map[string]interface{}{
|
||||
"app_versions": map[string]interface{}{
|
||||
"bose_app": "27.0.13-release",
|
||||
"bose_protocol": "1.0",
|
||||
},
|
||||
"api_versions": map[string]interface{}{
|
||||
"bose_streaming": "1.2",
|
||||
"bose_customer": "1.3",
|
||||
},
|
||||
"default": encodedDefaults,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func writeStockholmConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
jsonDir := filepath.Join(dir, "json")
|
||||
|
||||
if err := os.MkdirAll(jsonDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(jsonDir, "config.json"), []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write config.json: %v", err)
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestLoadConfig_ParsesVersions(t *testing.T) {
|
||||
content := makeConfigJSON(t, map[string]string{
|
||||
"d0": "https://streaming.bose.com/marge/",
|
||||
})
|
||||
dir := writeStockholmConfig(t, content)
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
if cfg.AppVersion != "27.0.13-release" {
|
||||
t.Errorf("AppVersion = %q, want %q", cfg.AppVersion, "27.0.13-release")
|
||||
}
|
||||
|
||||
if cfg.StreamingVersion != "1.2" {
|
||||
t.Errorf("StreamingVersion = %q, want %q", cfg.StreamingVersion, "1.2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_DefaultsForMissingAPIVersions(t *testing.T) {
|
||||
// api_versions absent → defaults to "1.0"
|
||||
raw := map[string]interface{}{
|
||||
"app_versions": map[string]interface{}{
|
||||
"bose_app": "27.0",
|
||||
"bose_protocol": "1.0",
|
||||
},
|
||||
"api_versions": map[string]interface{}{},
|
||||
"default": map[string]interface{}{},
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
|
||||
dir := writeStockholmConfig(t, string(data))
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
if cfg.StreamingVersion != "1.0" {
|
||||
t.Errorf("expected default StreamingVersion=1.0, got %q", cfg.StreamingVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteConfigURLs_ReplacesHostnames(t *testing.T) {
|
||||
content := makeConfigJSON(t, map[string]string{
|
||||
"d0": "https://streaming.bose.com/marge/",
|
||||
"d1": "https://downloads.bose.com/updates/",
|
||||
"d3": "https://content.api.bose.io/registry",
|
||||
})
|
||||
dir := writeStockholmConfig(t, content)
|
||||
|
||||
backendURL := "http://myserver:8000"
|
||||
if err := RewriteConfigURLs(dir, backendURL, backendURL, backendURL); err != nil {
|
||||
t.Fatalf("RewriteConfigURLs: %v", err)
|
||||
}
|
||||
|
||||
// Reload and verify
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig after rewrite: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.DefaultMargeURL, backendURL) {
|
||||
t.Errorf("DefaultMargeURL = %q, expected prefix %q", cfg.DefaultMargeURL, backendURL)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.DefaultUpdateURL, backendURL) {
|
||||
t.Errorf("DefaultUpdateURL = %q, expected prefix %q", cfg.DefaultUpdateURL, backendURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteConfigURLs_MargeURLUsedForStreaming(t *testing.T) {
|
||||
content := makeConfigJSON(t, map[string]string{
|
||||
"d0": "https://streaming.bose.com/",
|
||||
})
|
||||
dir := writeStockholmConfig(t, content)
|
||||
|
||||
backendURL := "http://backend:8000"
|
||||
margeURL := "http://backend:8000/marge"
|
||||
|
||||
if err := RewriteConfigURLs(dir, backendURL, margeURL, backendURL); err != nil {
|
||||
t.Fatalf("RewriteConfigURLs: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig after rewrite: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.DefaultMargeURL, margeURL) {
|
||||
t.Errorf("DefaultMargeURL = %q, expected prefix %q", cfg.DefaultMargeURL, margeURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteConfigURLs_AuthServiceURLHasTrailingSlash(t *testing.T) {
|
||||
content := makeConfigJSON(t, map[string]string{
|
||||
"d6": "oauth", // original Bose placeholder
|
||||
})
|
||||
dir := writeStockholmConfig(t, content)
|
||||
|
||||
backendURL := "http://backend:8000"
|
||||
|
||||
// Without trailing slash — function should add it.
|
||||
if err := RewriteConfigURLs(dir, backendURL, backendURL, backendURL); err != nil {
|
||||
t.Fatalf("RewriteConfigURLs: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig after rewrite: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(cfg.AuthServiceURL, "/") {
|
||||
t.Errorf("AuthServiceURL = %q, expected trailing slash", cfg.AuthServiceURL)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.AuthServiceURL, backendURL) {
|
||||
t.Errorf("AuthServiceURL = %q, expected prefix %q", cfg.AuthServiceURL, backendURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteConfigURLs_AuthServiceURL_ExplicitValue(t *testing.T) {
|
||||
content := makeConfigJSON(t, map[string]string{
|
||||
"d6": "oauth",
|
||||
})
|
||||
dir := writeStockholmConfig(t, content)
|
||||
|
||||
backendURL := "http://backend:8000"
|
||||
authURL := "http://auth.backend:8001"
|
||||
|
||||
if err := RewriteConfigURLs(dir, backendURL, backendURL, authURL); err != nil {
|
||||
t.Fatalf("RewriteConfigURLs: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig after rewrite: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.AuthServiceURL, authURL) {
|
||||
t.Errorf("AuthServiceURL = %q, expected prefix %q", cfg.AuthServiceURL, authURL)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(cfg.AuthServiceURL, "/") {
|
||||
t.Errorf("AuthServiceURL = %q, expected trailing slash", cfg.AuthServiceURL)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- MediaType helpers ----
|
||||
|
||||
func TestStreamingMediaType(t *testing.T) {
|
||||
cfg := &Config{StreamingVersion: "1.2"}
|
||||
want := "application/vnd.bose.streaming-v1.2+xml"
|
||||
|
||||
if got := cfg.StreamingMediaType(); got != want {
|
||||
t.Errorf("StreamingMediaType() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaTypeForPath(t *testing.T) {
|
||||
cfg := &Config{StreamingVersion: "1.2", CustomerVersion: "1.3"}
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/customer/login", "application/vnd.bose.customer-v1.3+xml"},
|
||||
{"/streaming/content", "application/vnd.bose.streaming-v1.2+xml"},
|
||||
{"/info", "application/xml"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := cfg.MediaTypeForPath(tc.path); got != tc.want {
|
||||
t.Errorf("MediaTypeForPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- LoadBackendConfig ----
|
||||
|
||||
func TestLoadBackendConfig_MissingFile_ReturnsDefaults(t *testing.T) {
|
||||
cfg := LoadBackendConfig(t.TempDir())
|
||||
|
||||
if cfg.FrontendLoggingLevel != 0 {
|
||||
t.Errorf("expected default FrontendLoggingLevel=0, got %d", cfg.FrontendLoggingLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackendConfig_ParsesLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgDir := filepath.Join(dir, "backend", "config")
|
||||
_ = os.MkdirAll(cfgDir, 0755)
|
||||
_ = os.WriteFile(filepath.Join(cfgDir, "backend-config.json"),
|
||||
[]byte(`{"frontendLoggingLevel": 3}`), 0644)
|
||||
|
||||
cfg := LoadBackendConfig(dir)
|
||||
|
||||
if cfg.FrontendLoggingLevel != 3 {
|
||||
t.Errorf("expected FrontendLoggingLevel=3, got %d", cfg.FrontendLoggingLevel)
|
||||
}
|
||||
|
||||
if !cfg.ShouldEnableFrontendDebug() {
|
||||
t.Error("expected ShouldEnableFrontendDebug=true for level 3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackendConfig_NegativeLevel_Clamped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgDir := filepath.Join(dir, "backend", "config")
|
||||
_ = os.MkdirAll(cfgDir, 0755)
|
||||
_ = os.WriteFile(filepath.Join(cfgDir, "backend-config.json"),
|
||||
[]byte(`{"frontendLoggingLevel": -1}`), 0644)
|
||||
|
||||
cfg := LoadBackendConfig(dir)
|
||||
|
||||
if cfg.FrontendLoggingLevel != 0 {
|
||||
t.Errorf("expected clamped FrontendLoggingLevel=0, got %d", cfg.FrontendLoggingLevel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ssdpAddr = "239.255.255.250:1900"
|
||||
rendererST = "urn:schemas-upnp-org:device:MediaRenderer:1"
|
||||
serverST = "urn:schemas-upnp-org:device:MediaServer:1"
|
||||
ssdpProbes = 3
|
||||
ssdpProbeIntervalMS = 350
|
||||
ssdpGraceMS = 1250
|
||||
ssdpReceiveSliceMS = 250
|
||||
ssdpMX = 1
|
||||
)
|
||||
|
||||
// RendererDevice is the payload pushed to the browser for a discovered speaker.
|
||||
type RendererDevice struct {
|
||||
UID string `json:"uID"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
// ServerDevice is the payload pushed for a discovered HRMS media server.
|
||||
type ServerDevice struct {
|
||||
UID string `json:"uID"`
|
||||
IP string `json:"ip"`
|
||||
Port string `json:"port"`
|
||||
}
|
||||
|
||||
// infoXML is used to unmarshal /info responses from speakers.
|
||||
type infoXML struct {
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
MargeAccountUUID string `xml:"margeAccountUUID"`
|
||||
}
|
||||
|
||||
// DiscoverRenderers performs SSDP MediaRenderer:1 discovery, fetches /info from
|
||||
// each speaker, optionally filters by expectedAccountID, and calls onDevice for
|
||||
// each accepted speaker incrementally.
|
||||
func DiscoverRenderers(expectedAccountID string, onDevice func(RendererDevice)) []RendererDevice {
|
||||
responses := ssdpSearch(rendererST)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
var results []RendererDevice
|
||||
|
||||
for _, resp := range responses {
|
||||
host := hostFromSSDPResponse(resp)
|
||||
if host == "" || seen[host] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[host] = true
|
||||
|
||||
info, err := fetchSpeakerInfo(host)
|
||||
if err != nil {
|
||||
log.Printf("[Stockholm SSDP] Failed to fetch /info from %s: %v", host, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if expectedAccountID != "" && info.MargeAccountUUID != expectedAccountID {
|
||||
log.Printf("[Stockholm SSDP] Skipping %s: account %q != %q", host, info.MargeAccountUUID, expectedAccountID)
|
||||
continue
|
||||
}
|
||||
|
||||
uid := strings.ToUpper(info.DeviceID)
|
||||
if uid == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
d := RendererDevice{UID: uid, IP: host}
|
||||
results = append(results, d)
|
||||
|
||||
if onDevice != nil {
|
||||
onDevice(d)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// DiscoverServers performs SSDP MediaServer:1 discovery and returns all found servers.
|
||||
func DiscoverServers() []ServerDevice {
|
||||
responses := ssdpSearch(serverST)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
var results []ServerDevice
|
||||
|
||||
for _, resp := range responses {
|
||||
location := resp["location"]
|
||||
if location == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
u, err := url.Parse(location)
|
||||
if err != nil || u.Host == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
|
||||
portStr := u.Port()
|
||||
if portStr == "" {
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
portStr = "443"
|
||||
default:
|
||||
portStr = "80"
|
||||
}
|
||||
}
|
||||
|
||||
key := host + ":" + portStr
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[key] = true
|
||||
|
||||
uid := normalizeUSN(resp["usn"], key)
|
||||
results = append(results, ServerDevice{UID: uid, IP: host, Port: portStr})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ssdpSearch sends SSDP M-SEARCH requests and returns raw response header maps.
|
||||
func ssdpSearch(searchTarget string) []map[string]string {
|
||||
ifaces := discoveryInterfaces()
|
||||
|
||||
if len(ifaces) == 0 {
|
||||
return searchOnInterface(searchTarget, nil)
|
||||
}
|
||||
|
||||
for _, iface := range ifaces {
|
||||
results := searchOnInterface(searchTarget, &iface)
|
||||
if len(results) > 0 {
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func searchOnInterface(searchTarget string, iface *net.Interface) []map[string]string {
|
||||
mcastAddr, err := net.ResolveUDPAddr("udp4", ssdpAddr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var conn *net.UDPConn
|
||||
if iface == nil {
|
||||
conn, err = net.ListenUDP("udp4", &net.UDPAddr{})
|
||||
} else {
|
||||
bindAddr := primaryIPv4(iface)
|
||||
if bindAddr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
conn, err = net.ListenUDP("udp4", &net.UDPAddr{IP: bindAddr})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
payload := []byte(buildMSearch(searchTarget))
|
||||
seen := make(map[string]map[string]string)
|
||||
|
||||
// 3 probes
|
||||
deadline := time.Now().Add(time.Duration(ssdpProbes*ssdpProbeIntervalMS+ssdpGraceMS) * time.Millisecond)
|
||||
_ = conn.SetReadDeadline(deadline)
|
||||
|
||||
for probe := 0; probe < ssdpProbes; probe++ {
|
||||
if _, err := conn.WriteToUDP(payload, mcastAddr); err != nil {
|
||||
log.Printf("[Stockholm SSDP] Send error: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
collectUntil(conn, searchTarget, seen, time.Now().Add(time.Duration(ssdpProbeIntervalMS)*time.Millisecond))
|
||||
}
|
||||
|
||||
collectUntil(conn, searchTarget, seen, time.Now().Add(time.Duration(ssdpGraceMS)*time.Millisecond))
|
||||
|
||||
result := make([]map[string]string, 0, len(seen))
|
||||
for _, v := range seen {
|
||||
result = append(result, v)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func collectUntil(conn *net.UDPConn, searchTarget string, seen map[string]map[string]string, until time.Time) {
|
||||
buf := make([]byte, 8192)
|
||||
|
||||
for {
|
||||
remaining := time.Until(until)
|
||||
if remaining <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
slice := time.Duration(ssdpReceiveSliceMS) * time.Millisecond
|
||||
if slice > remaining {
|
||||
slice = remaining
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(slice))
|
||||
|
||||
n, remoteAddr, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
headers := parseSSDPHeaders(buf[:n], remoteAddr.IP.String())
|
||||
|
||||
if !matchesST(headers, searchTarget) {
|
||||
continue
|
||||
}
|
||||
|
||||
key := responseKey(headers)
|
||||
if _, exists := seen[key]; !exists {
|
||||
seen[key] = headers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseSSDPHeaders(data []byte, remoteIP string) map[string]string {
|
||||
out := map[string]string{"remote-ip": remoteIP}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
|
||||
idx := strings.IndexByte(line, ':')
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
k := strings.ToLower(strings.TrimSpace(line[:idx]))
|
||||
v := strings.TrimSpace(line[idx+1:])
|
||||
|
||||
if _, exists := out[k]; !exists {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func matchesST(resp map[string]string, target string) bool {
|
||||
if st := resp["st"]; strings.EqualFold(st, target) {
|
||||
return true
|
||||
}
|
||||
|
||||
if usn := resp["usn"]; strings.Contains(strings.ToLower(usn), strings.ToLower(target)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func responseKey(resp map[string]string) string {
|
||||
return resp["usn"] + "|" + resp["location"] + "|" + resp["remote-ip"]
|
||||
}
|
||||
|
||||
func hostFromSSDPResponse(resp map[string]string) string {
|
||||
if loc := resp["location"]; loc != "" {
|
||||
if u, err := url.Parse(loc); err == nil && u.Host != "" {
|
||||
return u.Hostname()
|
||||
}
|
||||
}
|
||||
|
||||
return resp["remote-ip"]
|
||||
}
|
||||
|
||||
func fetchSpeakerInfo(host string) (*infoXML, error) {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
resp, err := client.Get(fmt.Sprintf("http://%s:8090/info", host))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var info infoXML
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func normalizeUSN(usn, fallback string) string {
|
||||
if usn == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Strip "::urn:..." suffix
|
||||
if i := strings.Index(usn, "::"); i >= 0 {
|
||||
usn = usn[:i]
|
||||
}
|
||||
|
||||
// Strip "uuid:" prefix
|
||||
if strings.HasPrefix(strings.ToLower(usn), "uuid:") {
|
||||
usn = usn[5:]
|
||||
}
|
||||
|
||||
if usn == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return usn
|
||||
}
|
||||
|
||||
func buildMSearch(st string) string {
|
||||
return strings.Join([]string{
|
||||
"M-SEARCH * HTTP/1.1",
|
||||
"Host:239.255.255.250:1900",
|
||||
`Man:"ssdp:discover"`,
|
||||
fmt.Sprintf("MX:%d", ssdpMX),
|
||||
"ST:" + st,
|
||||
"",
|
||||
"",
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
// discoveryInterfaces returns suitable network interfaces sorted by priority:
|
||||
// ethernet/en* first, then wifi/wl*, then others. Loopback/virtual/docker etc. are excluded.
|
||||
func discoveryInterfaces() []net.Interface {
|
||||
all, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ifaces []net.Interface
|
||||
|
||||
for _, iface := range all {
|
||||
if !isDiscoveryInterface(iface) {
|
||||
continue
|
||||
}
|
||||
|
||||
ifaces = append(ifaces, iface)
|
||||
}
|
||||
|
||||
// Sort: ethernet first (priority 0), wifi (1), others (2)
|
||||
for i := 0; i < len(ifaces); i++ {
|
||||
for j := i + 1; j < len(ifaces); j++ {
|
||||
if interfacePriority(ifaces[i]) > interfacePriority(ifaces[j]) {
|
||||
ifaces[i], ifaces[j] = ifaces[j], ifaces[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ifaces
|
||||
}
|
||||
|
||||
func isDiscoveryInterface(iface net.Interface) bool {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if iface.Flags&net.FlagMulticast == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
desc := strings.ToLower(iface.Name + " " + iface.Name)
|
||||
for _, banned := range []string{"docker", "vbox", "vmware", "hyper-v", "loopback", "bluetooth", "teredo", "tunnel"} {
|
||||
if strings.Contains(desc, banned) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return primaryIPv4(&iface) != nil
|
||||
}
|
||||
|
||||
func interfacePriority(iface net.Interface) int {
|
||||
name := strings.ToLower(iface.Name)
|
||||
if strings.HasPrefix(name, "eth") || strings.HasPrefix(name, "en") {
|
||||
return 0
|
||||
}
|
||||
|
||||
if strings.HasPrefix(name, "wl") || strings.Contains(name, "wifi") || strings.Contains(name, "wlan") {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
func primaryIPv4(iface *net.Interface) net.IP {
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
|
||||
if ip4 := ip.To4(); ip4 != nil && !ip4.IsLoopback() {
|
||||
return ip4
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler is the main entry point for the Stockholm frontend backend.
|
||||
type Handler struct {
|
||||
cfg *Config
|
||||
backendCfg *BackendConfig
|
||||
state *NativeState
|
||||
bridge *Bridge
|
||||
stockholmDir string
|
||||
}
|
||||
|
||||
// New initialises and returns a Stockholm Handler.
|
||||
//
|
||||
// stockholmDir is the path to the extracted Stockholm frontend (contains index.html).
|
||||
// workspaceRoot is used to locate backend/state and backend/config directories.
|
||||
// backendURL is the external URL of this service (used for config URL rewriting).
|
||||
// basePath is the URL prefix at which the Stockholm UI is mounted (e.g. "/stockholm");
|
||||
// pass "" to serve at the root.
|
||||
func New(stockholmDir, workspaceRoot, backendURL, basePath string) (*Handler, error) {
|
||||
if _, err := os.Stat(stockholmDir); err != nil {
|
||||
return nil, fmt.Errorf("stockholm dir not found at %q: %w", stockholmDir, err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(stockholmDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load stockholm config: %w", err)
|
||||
}
|
||||
|
||||
if backendURL != "" {
|
||||
margeURL := firstNonEmpty(os.Getenv("MARGE_URL"), backendURL)
|
||||
|
||||
authServiceURL := firstNonEmpty(os.Getenv("AUTH_SERVICE_URL"), backendURL)
|
||||
if err := RewriteConfigURLs(stockholmDir, backendURL, margeURL, authServiceURL); err != nil {
|
||||
log.Printf("[Stockholm] Warning: failed to rewrite config URLs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
backendCfg := LoadBackendConfig(workspaceRoot)
|
||||
|
||||
stateDir := filepath.Join(workspaceRoot, "backend", "state")
|
||||
if err := os.MkdirAll(stateDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create state dir: %w", err)
|
||||
}
|
||||
|
||||
state := NewNativeState(stateDir)
|
||||
if err := state.Load(); err != nil {
|
||||
log.Printf("[Stockholm] Warning: failed to load native state: %v", err)
|
||||
}
|
||||
|
||||
// Normalise basePath: no trailing slash, must start with "/" or be empty.
|
||||
if basePath != "" && !strings.HasPrefix(basePath, "/") {
|
||||
basePath = "/" + basePath
|
||||
}
|
||||
|
||||
basePath = strings.TrimRight(basePath, "/")
|
||||
|
||||
// Defence in depth: basePath is operator-provided (CLI flag /
|
||||
// STOCKHOLM_BASE_PATH env var), not request input — but if it
|
||||
// were ever set to "//evil.com" (typo or hostile env injection)
|
||||
// the bare-path redirect below would go scheme-relative to
|
||||
// evil.com. Reject any leading-double-slash and any backslash
|
||||
// so the redirect target can only ever be an absolute local
|
||||
// path. CodeQL go/bad-redirect-check raised the original
|
||||
// concern.
|
||||
if strings.HasPrefix(basePath, "//") || strings.HasPrefix(basePath, "/\\") || strings.ContainsAny(basePath, "\\") {
|
||||
return nil, fmt.Errorf("invalid stockholm base path %q: must be an absolute path starting with a single '/'", basePath)
|
||||
}
|
||||
|
||||
cfg.BasePath = basePath
|
||||
|
||||
state.SeedFromEnv(cfg)
|
||||
|
||||
bridge := newBridge(cfg, state)
|
||||
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
backendCfg: backendCfg,
|
||||
state: state,
|
||||
bridge: bridge,
|
||||
stockholmDir: stockholmDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Mount registers all Stockholm routes on the given chi router.
|
||||
// API routes (/api/native/*, /api/http-proxy) are registered under cfg.BasePath
|
||||
// because the patched JS uses window.__stockholmBase as a prefix for all API calls.
|
||||
// Static content is served under cfg.BasePath (e.g. /stockholm) if set,
|
||||
// otherwise at the root.
|
||||
func (h *Handler) Mount(r chi.Router) {
|
||||
apiBase := h.cfg.BasePath
|
||||
r.Post(apiBase+"/api/native/appSend", h.bridge.HandleAppSend)
|
||||
r.Get(apiBase+"/api/native/runQueue", h.bridge.HandleRunQueue)
|
||||
r.HandleFunc(apiBase+"/api/http-proxy", h.handleProxy)
|
||||
|
||||
if h.cfg.BasePath != "" {
|
||||
// Redirect bare /stockholm to /stockholm/ so the browser sets the correct
|
||||
// base URL for relative asset references.
|
||||
r.Get(h.cfg.BasePath, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, h.cfg.BasePath+"/", http.StatusMovedPermanently)
|
||||
})
|
||||
// Strip the base path prefix before passing to handleStatic so that
|
||||
// resolveStaticFile sees paths like "/" or "/index.html", not "/stockholm/".
|
||||
// r.Route does NOT strip r.URL.Path, so we must use http.StripPrefix explicitly.
|
||||
stripped := http.StripPrefix(h.cfg.BasePath, http.HandlerFunc(h.handleStatic))
|
||||
r.Get(h.cfg.BasePath+"/", stripped.ServeHTTP)
|
||||
r.Head(h.cfg.BasePath+"/", stripped.ServeHTTP)
|
||||
r.Get(h.cfg.BasePath+"/*", stripped.ServeHTTP)
|
||||
r.Head(h.cfg.BasePath+"/*", stripped.ServeHTTP)
|
||||
} else {
|
||||
// Serve static content at the root (catch-all at the end).
|
||||
r.Get("/*", h.handleStatic)
|
||||
r.Head("/*", h.handleStatic)
|
||||
r.Get("/", h.handleStatic)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
HandleProxy(w, r, h.cfg, h.state)
|
||||
}
|
||||
|
||||
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
ServeStatic(w, r, h.stockholmDir, h.backendCfg, h.state, h.cfg)
|
||||
}
|
||||
|
||||
// HandleStatic is the exported form of handleStatic, needed when mounting the
|
||||
// Stockholm static handler inside sub-routers (e.g. to resolve the /setup/ path
|
||||
// collision between the management API and the Stockholm setup wizard pages).
|
||||
func (h *Handler) HandleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
ServeStatic(w, r, h.stockholmDir, h.backendCfg, h.state, h.cfg)
|
||||
}
|
||||
|
||||
// Config returns the loaded Stockholm config (for integration with the proxy handler
|
||||
// that may need to inject BMX/marge headers).
|
||||
func (h *Handler) Config() *Config {
|
||||
return h.cfg
|
||||
}
|
||||
|
||||
// State returns the NativeState (for integration with handlers that need to read
|
||||
// auth tokens or account IDs).
|
||||
func (h *Handler) State() *NativeState {
|
||||
return h.state
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var blockedRequestHeaders = map[string]bool{
|
||||
"access-control-request-headers": true,
|
||||
"access-control-request-method": true,
|
||||
"connection": true,
|
||||
"content-length": true,
|
||||
"cookie": true,
|
||||
"forwarded": true,
|
||||
"host": true,
|
||||
"http2-settings": true,
|
||||
"keep-alive": true,
|
||||
"origin": true,
|
||||
"proxy-authenticate": true,
|
||||
"proxy-authorization": true,
|
||||
"referer": true,
|
||||
"sec-ch-ua": true,
|
||||
"sec-ch-ua-mobile": true,
|
||||
"sec-ch-ua-platform": true,
|
||||
"sec-fetch-dest": true,
|
||||
"sec-fetch-mode": true,
|
||||
"sec-fetch-site": true,
|
||||
"sec-fetch-user": true,
|
||||
"te": true,
|
||||
"trailer": true,
|
||||
"transfer-encoding": true,
|
||||
"upgrade": true,
|
||||
"x-forwarded-for": true,
|
||||
"x-forwarded-host": true,
|
||||
"x-forwarded-port": true,
|
||||
"x-forwarded-proto": true,
|
||||
"x-real-ip": true,
|
||||
"x-requested-with": true,
|
||||
}
|
||||
|
||||
var blockedResponseHeaders = map[string]bool{
|
||||
"access-control-allow-credentials": true,
|
||||
"access-control-allow-headers": true,
|
||||
"access-control-allow-methods": true,
|
||||
"access-control-allow-origin": true,
|
||||
"access-control-expose-headers": true,
|
||||
"access-control-max-age": true,
|
||||
"connection": true,
|
||||
"content-length": true,
|
||||
"keep-alive": true,
|
||||
"proxy-authenticate": true,
|
||||
"proxy-authorization": true,
|
||||
"set-cookie": true,
|
||||
"set-cookie2": true,
|
||||
"te": true,
|
||||
"trailer": true,
|
||||
"transfer-encoding": true,
|
||||
"upgrade": true,
|
||||
}
|
||||
|
||||
var proxyHTTPClient = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return nil // follow redirects
|
||||
},
|
||||
}
|
||||
|
||||
// HandleProxy serves the /api/http-proxy endpoint.
|
||||
func HandleProxy(w http.ResponseWriter, r *http.Request, cfg *Config, state *NativeState) {
|
||||
encodedTarget := r.URL.Query().Get("url")
|
||||
if encodedTarget == "" {
|
||||
http.Error(w, "Missing url query parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
decodedTarget, err := url.QueryUnescape(encodedTarget)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid proxy target encoding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
target, err := url.Parse(decodedTarget)
|
||||
if err != nil || target.Host == "" {
|
||||
http.Error(w, "Invalid proxy target", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(target.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
http.Error(w, "Unsupported proxy target", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if isProxyLoop(r, target) {
|
||||
http.Error(w, "Refusing to proxy proxy endpoint", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
effectiveTarget := applyOverrideURL(target, cfg, state)
|
||||
|
||||
resp, err := executeProxyRequest(r, effectiveTarget, body, cfg, state)
|
||||
if err != nil {
|
||||
log.Printf("[Stockholm proxy] %s %s failed: %v", r.Method, effectiveTarget, err)
|
||||
http.Error(w, "Proxy request failed", http.StatusBadGateway)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read proxy response", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Login retry logic
|
||||
if isLoginRequest(r.Method, effectiveTarget) {
|
||||
resp, respBody, effectiveTarget = handleLoginRetry(r, effectiveTarget, body, resp, respBody, cfg, state)
|
||||
captureSuccessfulLogin(resp, respBody, state)
|
||||
}
|
||||
|
||||
captureRefreshedToken(effectiveTarget, resp, cfg, state)
|
||||
relayProxyResponse(w, r.Method, resp, respBody)
|
||||
}
|
||||
|
||||
func executeProxyRequest(r *http.Request, target *url.URL, body []byte, cfg *Config, state *NativeState) (*http.Response, error) {
|
||||
method := strings.ToUpper(r.Method)
|
||||
|
||||
var bodyReader io.Reader
|
||||
if method != "GET" && method != "HEAD" && len(body) > 0 {
|
||||
bodyReader = strings.NewReader(string(body))
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), method, target.String(), bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Forward allowed request headers
|
||||
for k, vals := range r.Header {
|
||||
if blockedRequestHeaders[strings.ToLower(k)] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range sanitizeHeaderValues(vals) {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject backend headers
|
||||
injectBackendHeaders(req, target, cfg, state)
|
||||
|
||||
return proxyHTTPClient.Do(req)
|
||||
}
|
||||
|
||||
func injectBackendHeaders(req *http.Request, target *url.URL, cfg *Config, state *NativeState) {
|
||||
host := target.Hostname()
|
||||
path := target.Path
|
||||
|
||||
if cfg.IsBmxTarget(host) {
|
||||
injectIfMissing(req, "x-bmx-api-key", cfg.EncryptedBmxToken)
|
||||
injectIfMissing(req, "x-software-version", cfg.AppVersion)
|
||||
}
|
||||
|
||||
if cfg.IsMargeTarget(host, path) {
|
||||
mediaType := cfg.MediaTypeForPath(path)
|
||||
injectIfMissing(req, "Accept", mediaType)
|
||||
injectIfMissing(req, "Content-Type", mediaType)
|
||||
injectIfMissing(req, "ClientType", "SOUNDTOUCH_COMPUTER_APP")
|
||||
injectIfMissing(req, "GUID", firstNonEmpty(state.Get("guid"), state.Get("deviceGuid")))
|
||||
injectIfMissing(req, "version_NativeFrameVersion", state.Get("nativeFrameVersion"))
|
||||
injectIfMissing(req, "version_StockholmVersion", cfg.AppVersion)
|
||||
injectIfMissing(req, "version_ProtocolVersion", cfg.ProtocolVersion)
|
||||
|
||||
if cfg.MargeServerKeyHeader != "" && cfg.MargeServerKey != "" {
|
||||
injectIfMissing(req, cfg.MargeServerKeyHeader, cfg.MargeServerKey)
|
||||
}
|
||||
|
||||
if shouldInjectAuth(path) {
|
||||
injectIfMissing(req, "Authorization", state.Get("margeAuthToken"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldInjectAuth(path string) bool {
|
||||
p := strings.ToLower(path)
|
||||
if strings.HasSuffix(p, "/streaming/account/login") {
|
||||
return false
|
||||
}
|
||||
|
||||
if p == "/streaming/account" || p == "/streaming/account/" {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.Contains(p, "/streaming/account/email/") && strings.HasSuffix(p, "/environment") {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(p, "/customer/account/password/email/") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func injectIfMissing(req *http.Request, name, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Header.Get(name) != "" {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
|
||||
func sanitizeHeaderValues(vals []string) []string {
|
||||
out := make([]string, 0, len(vals))
|
||||
for _, v := range vals {
|
||||
t := strings.TrimSpace(v)
|
||||
if t == "" || strings.EqualFold(t, "null") || strings.EqualFold(t, "undefined") {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, t)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func handleLoginRetry(
|
||||
r *http.Request,
|
||||
target *url.URL,
|
||||
body []byte,
|
||||
resp *http.Response,
|
||||
respBody []byte,
|
||||
cfg *Config,
|
||||
state *NativeState,
|
||||
) (*http.Response, []byte, *url.URL) {
|
||||
if extractXMLStatusCode(respBody) != "4033" {
|
||||
return resp, respBody, target
|
||||
}
|
||||
|
||||
email, password := parseLoginCredentials(body)
|
||||
if email == "" {
|
||||
return resp, respBody, target
|
||||
}
|
||||
|
||||
env := fetchEnvironment(r, target, email, password, cfg, state)
|
||||
if env == nil || env.streamingURL == "" {
|
||||
return resp, respBody, target
|
||||
}
|
||||
|
||||
state.PutMany(map[string]string{
|
||||
"overrideMargeURL": normalizeBaseURL(env.streamingURL),
|
||||
"overrideUpdateURL": normalizeBaseURL(env.updateURL),
|
||||
})
|
||||
|
||||
retryTarget := buildURIFromBase(env.streamingURL, target.Path, target.RawQuery)
|
||||
if retryTarget == nil {
|
||||
return resp, respBody, target
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
retryResp, err := executeProxyRequest(r, retryTarget, body, cfg, state)
|
||||
if err != nil {
|
||||
log.Printf("[Stockholm proxy] Login retry failed: %v", err)
|
||||
return resp, respBody, target
|
||||
}
|
||||
|
||||
retryBody, err := io.ReadAll(retryResp.Body)
|
||||
_ = retryResp.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
return resp, respBody, target
|
||||
}
|
||||
|
||||
return retryResp, retryBody, retryTarget
|
||||
}
|
||||
|
||||
func fetchEnvironment(r *http.Request, loginTarget *url.URL, email, password string, cfg *Config, state *NativeState) *environmentInfo {
|
||||
// Build environment URL
|
||||
prefix := margePathPrefix(loginTarget.Path)
|
||||
envPath := prefix + "/streaming/account/email/" + url.PathEscape(email) + "/environment"
|
||||
|
||||
envTarget := buildURIFromBase(loginTarget.Scheme+"://"+loginTarget.Host+"/", envPath, "")
|
||||
if envTarget == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
envReq, err := http.NewRequestWithContext(r.Context(), "GET", envTarget.String(), nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy allowed headers from original request
|
||||
for k, vals := range r.Header {
|
||||
if blockedRequestHeaders[strings.ToLower(k)] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range sanitizeHeaderValues(vals) {
|
||||
envReq.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
raw := email + ":" + password
|
||||
envReq.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(raw)))
|
||||
injectBackendHeaders(envReq, envTarget, cfg, state)
|
||||
|
||||
envResp, err := proxyHTTPClient.Do(envReq)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() { _ = envResp.Body.Close() }()
|
||||
|
||||
if envResp.StatusCode != 200 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(envResp.Body)
|
||||
|
||||
return extractEnvironment(body)
|
||||
}
|
||||
|
||||
func captureSuccessfulLogin(resp *http.Response, body []byte, state *NativeState) {
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]string)
|
||||
|
||||
if accountID := extractXMLAccountID(body); accountID != "" {
|
||||
updates["margeAccountID"] = accountID
|
||||
}
|
||||
|
||||
if creds := resp.Header.Get("Credentials"); creds != "" {
|
||||
updates["margeAuthToken"] = creds
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
state.PutMany(updates)
|
||||
}
|
||||
}
|
||||
|
||||
func captureRefreshedToken(target *url.URL, resp *http.Response, cfg *Config, state *NativeState) {
|
||||
if target == nil || !cfg.IsMargeTarget(target.Hostname(), target.Path) {
|
||||
return
|
||||
}
|
||||
|
||||
if refreshed := resp.Header.Get("Refresh"); refreshed != "" {
|
||||
state.Set("margeAuthToken", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
func relayProxyResponse(w http.ResponseWriter, method string, resp *http.Response, body []byte) {
|
||||
for k, vals := range resp.Header {
|
||||
kl := strings.ToLower(k)
|
||||
if kl == "" || strings.HasPrefix(kl, ":") || blockedResponseHeaders[kl] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range vals {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
bodyAllowed := method != "HEAD" && resp.StatusCode != 204 && resp.StatusCode != 304
|
||||
if bodyAllowed {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
}
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
if bodyAllowed {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
func isLoginRequest(method string, target *url.URL) bool {
|
||||
return strings.EqualFold(method, "POST") &&
|
||||
target != nil &&
|
||||
strings.HasSuffix(strings.ToLower(target.Path), "/streaming/account/login")
|
||||
}
|
||||
|
||||
func isProxyLoop(r *http.Request, target *url.URL) bool {
|
||||
if !strings.HasPrefix(target.Path, "/api/http-proxy") {
|
||||
return false
|
||||
}
|
||||
|
||||
host := target.Hostname()
|
||||
targetPort := target.Port()
|
||||
|
||||
localHost, localPort, _ := net.SplitHostPort(r.Host)
|
||||
if localHost == "" {
|
||||
localHost = r.Host
|
||||
}
|
||||
|
||||
if targetPort != "" && targetPort == localPort {
|
||||
if strings.EqualFold(host, localHost) ||
|
||||
strings.EqualFold(host, "localhost") ||
|
||||
host == "127.0.0.1" || host == "::1" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Also check forwarded headers
|
||||
extHost := resolveExternalHost(r)
|
||||
extPort := resolveExternalPort(r)
|
||||
|
||||
if extHost != "" && strings.EqualFold(host, extHost) {
|
||||
tp, _ := parsePort(target.Port(), target.Scheme)
|
||||
if tp == extPort {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveExternalHost(r *http.Request) string {
|
||||
if v := r.Header.Get("X-Forwarded-Host"); v != "" {
|
||||
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
|
||||
return v[:idx]
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
if v := r.Host; v != "" {
|
||||
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
|
||||
return v[:idx]
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveExternalPort(r *http.Request) int {
|
||||
if v := r.Header.Get("X-Forwarded-Port"); v != "" {
|
||||
if p, err := parsePort(v, ""); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
if v := r.Header.Get("X-Forwarded-Host"); v != "" {
|
||||
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
|
||||
if p, err := parsePort(v[idx+1:], ""); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if v := r.Host; v != "" {
|
||||
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
|
||||
if p, err := parsePort(v[idx+1:], ""); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
|
||||
return 443
|
||||
}
|
||||
|
||||
return 80
|
||||
}
|
||||
|
||||
func parsePort(portStr, scheme string) (int, error) {
|
||||
if portStr != "" {
|
||||
var p int
|
||||
|
||||
_, err := fmt.Sscanf(portStr, "%d", &p)
|
||||
|
||||
return p, err
|
||||
}
|
||||
|
||||
switch strings.ToLower(scheme) {
|
||||
case "https":
|
||||
return 443, nil
|
||||
case "http":
|
||||
return 80, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("no port")
|
||||
}
|
||||
|
||||
func applyOverrideURL(target *url.URL, cfg *Config, state *NativeState) *url.URL {
|
||||
if !cfg.IsMargeTarget(target.Hostname(), target.Path) {
|
||||
return target
|
||||
}
|
||||
|
||||
override := normalizeBaseURL(state.Get("overrideMargeURL"))
|
||||
if override == "" {
|
||||
return target
|
||||
}
|
||||
|
||||
result := buildURIFromBase(override, target.Path, target.RawQuery)
|
||||
if result == nil {
|
||||
return target
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func buildURIFromBase(base, path, query string) *url.URL {
|
||||
base = normalizeBaseURL(base)
|
||||
if base == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
u.Path = path
|
||||
u.RawQuery = query
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func margePathPrefix(path string) string {
|
||||
p := strings.ToLower(path)
|
||||
|
||||
idx := strings.Index(p, "/streaming/")
|
||||
if idx <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return path[:idx]
|
||||
}
|
||||
|
||||
// XML helpers for login retry
|
||||
|
||||
type xmlStatusCode struct {
|
||||
StatusCode string `xml:"status-code"`
|
||||
}
|
||||
|
||||
type xmlAccountAttr struct {
|
||||
ID string `xml:"id,attr"`
|
||||
}
|
||||
|
||||
type xmlLoginBody struct {
|
||||
Username string `xml:"username"`
|
||||
Password string `xml:"password"`
|
||||
}
|
||||
|
||||
type xmlEnvironment struct {
|
||||
StreamingURL string `xml:"streamingURL"`
|
||||
UpdateURL string `xml:"updateURL"`
|
||||
}
|
||||
|
||||
type environmentInfo struct {
|
||||
streamingURL string
|
||||
updateURL string
|
||||
}
|
||||
|
||||
func extractXMLStatusCode(body []byte) string {
|
||||
var v xmlStatusCode
|
||||
if err := xml.Unmarshal(body, &v); err == nil && v.StatusCode != "" {
|
||||
return v.StatusCode
|
||||
}
|
||||
// Try finding in any wrapper element
|
||||
type wrapper struct {
|
||||
StatusCode string `xml:"status-code"`
|
||||
}
|
||||
|
||||
var w wrapper
|
||||
|
||||
_ = xml.Unmarshal(body, &w)
|
||||
|
||||
return w.StatusCode
|
||||
}
|
||||
|
||||
func parseLoginCredentials(body []byte) (email, password string) {
|
||||
var login xmlLoginBody
|
||||
if err := xml.Unmarshal(body, &login); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(login.Username), login.Password
|
||||
}
|
||||
|
||||
func extractXMLAccountID(body []byte) string {
|
||||
type accountWrapper struct {
|
||||
Account xmlAccountAttr `xml:"account"`
|
||||
}
|
||||
|
||||
var w accountWrapper
|
||||
if err := xml.Unmarshal(body, &w); err == nil && w.Account.ID != "" {
|
||||
return w.Account.ID
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractEnvironment(body []byte) *environmentInfo {
|
||||
var env xmlEnvironment
|
||||
if err := xml.Unmarshal(body, &env); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if env.StreamingURL == "" && env.UpdateURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &environmentInfo{streamingURL: env.StreamingURL, updateURL: env.UpdateURL}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// NativeState is a flat string key→value store persisted to native-state.json.
|
||||
// Every Set call writes through to disk.
|
||||
type NativeState struct {
|
||||
mu sync.RWMutex
|
||||
data map[string]string
|
||||
path string
|
||||
}
|
||||
|
||||
// NewNativeState creates a NativeState that persists to stateDir/native-state.json.
|
||||
func NewNativeState(stateDir string) *NativeState {
|
||||
return &NativeState{
|
||||
data: make(map[string]string),
|
||||
path: filepath.Join(stateDir, "native-state.json"),
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the state file from disk. Non-existent file is not an error.
|
||||
func (s *NativeState) Load() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("read native-state: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return fmt.Errorf("parse native-state: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for k, v := range raw {
|
||||
s.data[k] = stringifyScalar(v)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedFromEnv seeds Marge auth/account from environment variables and
|
||||
// sets first-run defaults (guid, nativeFrameVersion, authServer, constant.kilo).
|
||||
func (s *NativeState) SeedFromEnv(cfg *Config) {
|
||||
updates := make(map[string]string)
|
||||
|
||||
// Marge session from env
|
||||
if v := firstNonEmpty(os.Getenv("margeAuthToken"), os.Getenv("MARGE_AUTH_TOKEN")); v != "" {
|
||||
updates["margeAuthToken"] = v
|
||||
}
|
||||
|
||||
if v := firstNonEmpty(os.Getenv("margeAccountID"), os.Getenv("MARGE_ACCOUNT_ID")); v != "" {
|
||||
updates["margeAccountID"] = v
|
||||
}
|
||||
|
||||
if s.Get("constant.kilo") == "" {
|
||||
updates["constant.kilo"] = kiloDefaultValue
|
||||
}
|
||||
|
||||
// First-run defaults that require a persisted value
|
||||
if s.Get("authServer") == "" {
|
||||
updates["authServer"] = "0"
|
||||
}
|
||||
|
||||
// GUID: use existing or generate new
|
||||
existingGUID := firstNonEmpty(s.Get("guid"), s.Get("deviceGuid"))
|
||||
if existingGUID == "" {
|
||||
existingGUID = randomHexUUID()
|
||||
}
|
||||
|
||||
if s.Get("guid") == "" {
|
||||
updates["guid"] = existingGUID
|
||||
}
|
||||
|
||||
if s.Get("deviceGuid") == "" {
|
||||
updates["deviceGuid"] = existingGUID
|
||||
}
|
||||
|
||||
// Version info from config
|
||||
if cfg != nil {
|
||||
fullVersion := firstNonEmpty(s.Get("frame_version"), cfg.AppVersion)
|
||||
shortVersion := firstNonEmpty(ExtractVersionPrefix(s.Get("nativeFrameVersion")),
|
||||
ExtractVersionPrefix(s.Get("frame_version")),
|
||||
ExtractVersionPrefix(cfg.AppVersion))
|
||||
|
||||
if s.Get("nativeFrameVersion") == "" && shortVersion != "" {
|
||||
updates["nativeFrameVersion"] = shortVersion
|
||||
}
|
||||
|
||||
if s.Get("frame_version") == "" && fullVersion != "" {
|
||||
updates["frame_version"] = fullVersion
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
s.putMany(updates)
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the value for key, or "" if absent.
|
||||
func (s *NativeState) Get(key string) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.data[key]
|
||||
}
|
||||
|
||||
// Set stores key→value and persists to disk.
|
||||
func (s *NativeState) Set(key, value string) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.data[key] = value
|
||||
s.mu.Unlock()
|
||||
s.persist()
|
||||
}
|
||||
|
||||
// PutMany stores multiple key→value pairs and persists once.
|
||||
func (s *NativeState) PutMany(updates map[string]string) {
|
||||
if len(updates) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
s.putMany(updates)
|
||||
}
|
||||
|
||||
func (s *NativeState) putMany(updates map[string]string) {
|
||||
s.mu.Lock()
|
||||
changed := false
|
||||
|
||||
for k, v := range updates {
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if prev, ok := s.data[k]; !ok || prev != v {
|
||||
s.data[k] = v
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if changed {
|
||||
s.persist()
|
||||
}
|
||||
}
|
||||
|
||||
// AuthServer returns the authServer value normalised to "0"–"3".
|
||||
func (s *NativeState) AuthServer() string {
|
||||
v := s.Get("authServer")
|
||||
switch v {
|
||||
case "0", "1", "2", "3":
|
||||
return v
|
||||
default:
|
||||
return "0"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NativeState) persist() {
|
||||
s.mu.RLock()
|
||||
|
||||
snapshot := make(map[string]string, len(s.data))
|
||||
for k, v := range s.data {
|
||||
snapshot[k] = v
|
||||
}
|
||||
|
||||
s.mu.RUnlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
log.Printf("[Stockholm] Failed to create state dir: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b, err := json.MarshalIndent(snapshot, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("[Stockholm] Failed to marshal native state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(s.path, append(b, '\n'), 0644); err != nil {
|
||||
log.Printf("[Stockholm] Failed to persist native state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func stringifyScalar(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
if t {
|
||||
return "true"
|
||||
}
|
||||
|
||||
return "false"
|
||||
case float64:
|
||||
// JSON numbers decode to float64
|
||||
if t == float64(int64(t)) {
|
||||
return fmt.Sprintf("%d", int64(t))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%g", t)
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNativeState_SetGet(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
state.Set("foo", "bar")
|
||||
|
||||
if got := state.Get("foo"); got != "bar" {
|
||||
t.Errorf("Get(foo) = %q, want bar", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_GetMissing_ReturnsEmpty(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
if got := state.Get("does-not-exist"); got != "" {
|
||||
t.Errorf("expected empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_SetEmpty_IsNoOp(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
state.Set("", "value")
|
||||
|
||||
// Empty key should not be stored
|
||||
if got := state.Get(""); got != "" {
|
||||
t.Errorf("expected empty string for empty key, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_PersistsAndLoads(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
state := NewNativeState(dir)
|
||||
|
||||
state.Set("key1", "val1")
|
||||
state.Set("key2", "val2")
|
||||
|
||||
// Reload from disk
|
||||
state2 := NewNativeState(dir)
|
||||
if err := state2.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
if got := state2.Get("key1"); got != "val1" {
|
||||
t.Errorf("after reload, key1 = %q, want val1", got)
|
||||
}
|
||||
|
||||
if got := state2.Get("key2"); got != "val2" {
|
||||
t.Errorf("after reload, key2 = %q, want val2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_Load_MissingFile_IsOK(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
if err := state.Load(); err != nil {
|
||||
t.Errorf("Load on missing file should not error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_Load_CorruptFile_ReturnsError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
statePath := filepath.Join(dir, "native-state.json")
|
||||
_ = os.WriteFile(statePath, []byte("not json"), 0644)
|
||||
|
||||
state := NewNativeState(dir)
|
||||
|
||||
if err := state.Load(); err == nil {
|
||||
t.Error("expected error for corrupt JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_PutMany(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
state.PutMany(map[string]string{
|
||||
"a": "1",
|
||||
"b": "2",
|
||||
})
|
||||
|
||||
if got := state.Get("a"); got != "1" {
|
||||
t.Errorf("expected a=1, got %q", got)
|
||||
}
|
||||
|
||||
if got := state.Get("b"); got != "2" {
|
||||
t.Errorf("expected b=2, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_PutMany_Empty_IsNoOp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
state := NewNativeState(dir)
|
||||
|
||||
state.PutMany(nil)
|
||||
state.PutMany(map[string]string{})
|
||||
|
||||
// State file should not be created for no-op
|
||||
_, err := os.Stat(filepath.Join(dir, "native-state.json"))
|
||||
if err == nil {
|
||||
t.Error("expected no state file to be created for empty PutMany")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_AuthServer_ValidValues(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
for _, v := range []string{"0", "1", "2", "3"} {
|
||||
state.Set("authServer", v)
|
||||
|
||||
if got := state.AuthServer(); got != v {
|
||||
t.Errorf("AuthServer() = %q, want %q", got, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_AuthServer_InvalidDefault(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
state.Set("authServer", "99")
|
||||
|
||||
if got := state.AuthServer(); got != "0" {
|
||||
t.Errorf("expected default 0 for invalid authServer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_SeedFromEnv_SetsGUIDAndDefaults(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
state.SeedFromEnv(nil)
|
||||
|
||||
if got := state.Get("guid"); got == "" {
|
||||
t.Error("expected guid to be seeded")
|
||||
}
|
||||
|
||||
if got := state.Get("deviceGuid"); got == "" {
|
||||
t.Error("expected deviceGuid to be seeded")
|
||||
}
|
||||
|
||||
if got := state.Get("authServer"); got != "0" {
|
||||
t.Errorf("expected authServer=0, got %q", got)
|
||||
}
|
||||
|
||||
if got := state.Get("constant.kilo"); got != "a7928d7b43dcd49f0af31e5aeed26458" {
|
||||
t.Errorf("unexpected kilo value: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_SeedFromEnv_GUIDConsistent(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
|
||||
state.SeedFromEnv(nil)
|
||||
|
||||
guid := state.Get("guid")
|
||||
deviceGuid := state.Get("deviceGuid")
|
||||
|
||||
if guid != deviceGuid {
|
||||
t.Errorf("expected guid == deviceGuid, got %q vs %q", guid, deviceGuid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_SeedFromEnv_DoesNotOverwriteExistingGUID(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
state.Set("guid", "existing-guid")
|
||||
|
||||
state.SeedFromEnv(nil)
|
||||
|
||||
if got := state.Get("guid"); got != "existing-guid" {
|
||||
t.Errorf("expected existing guid to be preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_SeedFromEnv_SetsVersionFromConfig(t *testing.T) {
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{AppVersion: "27.0.13-release"}
|
||||
|
||||
state.SeedFromEnv(cfg)
|
||||
|
||||
if got := state.Get("nativeFrameVersion"); got != "27.0.13" {
|
||||
t.Errorf("expected nativeFrameVersion=27.0.13, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeState_PersistFileContainsJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
state := NewNativeState(dir)
|
||||
state.Set("hello", "world")
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "native-state.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read state file: %v", err)
|
||||
}
|
||||
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("state file is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if m["hello"] != "world" {
|
||||
t.Errorf("expected hello=world in state file, got %q", m["hello"])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- stringifyScalar ----
|
||||
|
||||
func TestStringifyScalar(t *testing.T) {
|
||||
cases := []struct {
|
||||
input interface{}
|
||||
want string
|
||||
}{
|
||||
{nil, ""},
|
||||
{"hello", "hello"},
|
||||
{true, "true"},
|
||||
{false, "false"},
|
||||
{float64(42), "42"},
|
||||
{float64(3.14), "3.14"},
|
||||
{[]int{1, 2}, `[1,2]`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := stringifyScalar(tc.input); got != tc.want {
|
||||
t.Errorf("stringifyScalar(%v) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// contentTypeFor returns the MIME type for a file based on its extension.
|
||||
func contentTypeFor(name string) string {
|
||||
n := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.HasSuffix(n, ".html"):
|
||||
return "text/html; charset=UTF-8"
|
||||
case strings.HasSuffix(n, ".js"):
|
||||
return "application/javascript; charset=UTF-8"
|
||||
case strings.HasSuffix(n, ".css"):
|
||||
return "text/css; charset=UTF-8"
|
||||
case strings.HasSuffix(n, ".json"):
|
||||
return "application/json; charset=UTF-8"
|
||||
case strings.HasSuffix(n, ".xml"):
|
||||
return "application/xml; charset=UTF-8"
|
||||
case strings.HasSuffix(n, ".svg"):
|
||||
return "image/svg+xml"
|
||||
case strings.HasSuffix(n, ".png"):
|
||||
return "image/png"
|
||||
case strings.HasSuffix(n, ".jpg"), strings.HasSuffix(n, ".jpeg"):
|
||||
return "image/jpeg"
|
||||
case strings.HasSuffix(n, ".gif"):
|
||||
return "image/gif"
|
||||
case strings.HasSuffix(n, ".ttf"):
|
||||
return "font/ttf"
|
||||
case strings.HasSuffix(n, ".otf"):
|
||||
return "font/otf"
|
||||
case strings.HasSuffix(n, ".txt"):
|
||||
return "text/plain; charset=UTF-8"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// ServeStatic handles all static file requests for the Stockholm frontend.
|
||||
func ServeStatic(w http.ResponseWriter, r *http.Request, stockholmDir string, backendCfg *BackendConfig, state *NativeState, cfg *Config) {
|
||||
method := strings.ToUpper(r.Method)
|
||||
if method != http.MethodGet && method != http.MethodHead {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
file, rel, err := resolveStaticFile(r.URL.Path, stockholmDir)
|
||||
if err != nil {
|
||||
log.Printf("[Stockholm static] Path traversal rejected: %s", r.URL.Path)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
info, err := os.Stat(file)
|
||||
if err != nil || info.IsDir() {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ct := contentTypeFor(file)
|
||||
|
||||
if ct == "text/html; charset=UTF-8" && isBootstrapTarget(rel) {
|
||||
body = injectBootstrap(body, state, cfg)
|
||||
}
|
||||
|
||||
// Frontend logging cookie
|
||||
if backendCfg.ShouldEnableFrontendDebug() {
|
||||
w.Header().Add("Set-Cookie", fmt.Sprintf("stockholmFrontendLoggingLevel=%d; Path=/; SameSite=Lax", backendCfg.FrontendLoggingLevel))
|
||||
} else {
|
||||
w.Header().Add("Set-Cookie", "stockholmFrontendLoggingLevel=; Max-Age=0; Path=/; SameSite=Lax")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
if method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func resolveStaticFile(rawPath, stockholmDir string) (filePath, relPath string, err error) {
|
||||
if rawPath == "" || rawPath == "/" {
|
||||
rawPath = "/index.html"
|
||||
}
|
||||
|
||||
// Strip leading slash, resolve relative to stockholmDir
|
||||
clean := filepath.Clean(strings.TrimPrefix(rawPath, "/"))
|
||||
resolved := filepath.Join(stockholmDir, clean)
|
||||
|
||||
// Security: reject path traversal
|
||||
absStockholm, _ := filepath.Abs(stockholmDir)
|
||||
absResolved, _ := filepath.Abs(resolved)
|
||||
|
||||
if !strings.HasPrefix(absResolved+string(filepath.Separator), absStockholm+string(filepath.Separator)) &&
|
||||
absResolved != absStockholm {
|
||||
return "", "", fmt.Errorf("path outside stockholm root")
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(absResolved, absStockholm+string(filepath.Separator))
|
||||
rel = strings.ReplaceAll(rel, string(filepath.Separator), "/")
|
||||
|
||||
// Directory → try index.html
|
||||
info, statErr := os.Stat(resolved)
|
||||
if statErr == nil && info.IsDir() {
|
||||
resolved = filepath.Join(resolved, "index.html")
|
||||
rel = strings.TrimPrefix(resolved, absStockholm+string(filepath.Separator))
|
||||
rel = strings.ReplaceAll(rel, string(filepath.Separator), "/")
|
||||
}
|
||||
|
||||
return resolved, rel, nil
|
||||
}
|
||||
|
||||
func isBootstrapTarget(relPath string) bool {
|
||||
p := strings.ToLower(relPath)
|
||||
return p == "index.html" || p == "setup/index.html"
|
||||
}
|
||||
|
||||
func injectBootstrap(html []byte, state *NativeState, cfg *Config) []byte {
|
||||
content := string(html)
|
||||
|
||||
if strings.Contains(content, "window.StockholmBrowserBootstrap") {
|
||||
return html
|
||||
}
|
||||
|
||||
idx := strings.Index(content, "</head>")
|
||||
if idx < 0 {
|
||||
return html
|
||||
}
|
||||
|
||||
script := buildBootstrapScript(state, cfg)
|
||||
injected := content[:idx] + script + content[idx:]
|
||||
|
||||
return []byte(injected)
|
||||
}
|
||||
|
||||
func buildBootstrapScript(state *NativeState, cfg *Config) string {
|
||||
guid := firstNonEmpty(state.Get("guid"), state.Get("deviceGuid"))
|
||||
nativeVersion := firstNonEmpty(state.Get("frame_version"), cfg.AppVersion)
|
||||
authServer := state.AuthServer()
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"authServer": authServer,
|
||||
"guid": guid,
|
||||
"nativeVersion": nativeVersion,
|
||||
"frameConfig": map[string]interface{}{},
|
||||
"basePath": cfg.BasePath,
|
||||
}
|
||||
|
||||
bootstrapJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("[Stockholm static] Failed to marshal bootstrap payload: %v", err)
|
||||
|
||||
bootstrapJSON = []byte("{}")
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<script>
|
||||
(function () {
|
||||
window.StockholmBrowserBootstrap = %s;
|
||||
// __stockholmBase lets the bridge JS files resolve API URLs when Stockholm
|
||||
// is mounted under a prefix such as /stockholm.
|
||||
window.__stockholmBase = window.StockholmBrowserBootstrap.basePath || "";
|
||||
var bootstrap = window.StockholmBrowserBootstrap || {};
|
||||
|
||||
function toBase64(value) {
|
||||
return window.btoa(unescape(encodeURIComponent(String(value))));
|
||||
}
|
||||
|
||||
function mergeFrameConfig(config) {
|
||||
if (!bootstrap.frameConfig || typeof bootstrap.frameConfig !== "object") {
|
||||
return config;
|
||||
}
|
||||
config = config || {};
|
||||
config.default = config.default || {};
|
||||
Object.keys(bootstrap.frameConfig).forEach(function (key) {
|
||||
var value = bootstrap.frameConfig[key];
|
||||
if (!/^f\d+$/.test(key) || value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
var targetKey = "d" + key.substring(1);
|
||||
if (config.default[targetKey] === undefined || config.default[targetKey] === null
|
||||
|| config.default[targetKey] === "") {
|
||||
config.default[targetKey] = toBase64(value);
|
||||
}
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
var originalGetURLParams = window.getURLParams;
|
||||
if (typeof originalGetURLParams === "function") {
|
||||
window.getURLParams = function (name, url) {
|
||||
var value = originalGetURLParams(name, url);
|
||||
if (value !== null && value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
if (name === "native_version" && bootstrap.nativeVersion) {
|
||||
return bootstrap.nativeVersion;
|
||||
}
|
||||
if (name === "authServer" && bootstrap.authServer !== undefined && bootstrap.authServer !== null) {
|
||||
return String(bootstrap.authServer);
|
||||
}
|
||||
if (name === "guid" && bootstrap.guid) {
|
||||
return bootstrap.guid;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
var originalGetUserAgentValue = window.getUserAgentValue;
|
||||
if (typeof originalGetUserAgentValue === "function") {
|
||||
window.getUserAgentValue = function (name) {
|
||||
var value = originalGetUserAgentValue(name);
|
||||
if ((!value || value === "") && name === "_app" && bootstrap.guid) {
|
||||
return bootstrap.guid;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
if ((!window.guid || window.guid === "") && bootstrap.guid) {
|
||||
window.guid = bootstrap.guid;
|
||||
}
|
||||
if ((!window.frame_version || window.frame_version === "") && bootstrap.nativeVersion) {
|
||||
window.frame_version = bootstrap.nativeVersion;
|
||||
}
|
||||
if ((window.auth_server === undefined || window.auth_server === null || window.auth_server === "")
|
||||
&& bootstrap.authServer !== undefined && bootstrap.authServer !== null) {
|
||||
window.auth_server = bootstrap.authServer;
|
||||
}
|
||||
|
||||
var originalSettingsLoad = window.settingsLoad;
|
||||
if (typeof originalSettingsLoad === "function") {
|
||||
window.settingsLoad = function (config) {
|
||||
return originalSettingsLoad(mergeFrameConfig(config));
|
||||
};
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
`, string(bootstrapJSON))
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- injectBootstrap ----
|
||||
|
||||
func TestInjectBootstrap_InjectsBeforeHead(t *testing.T) {
|
||||
html := []byte(`<html><head><title>T</title></head><body></body></html>`)
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{AppVersion: "27.0"}
|
||||
|
||||
got := string(injectBootstrap(html, state, cfg))
|
||||
|
||||
if !strings.Contains(got, "window.StockholmBrowserBootstrap") {
|
||||
t.Error("expected bootstrap script to be injected")
|
||||
}
|
||||
|
||||
scriptEnd := strings.Index(got, "</script>")
|
||||
headEnd := strings.Index(got, "</head>")
|
||||
|
||||
if scriptEnd < 0 || headEnd < 0 || scriptEnd > headEnd {
|
||||
t.Error("expected bootstrap script to appear before </head>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectBootstrap_Idempotent(t *testing.T) {
|
||||
html := []byte(`<html><head><script>window.StockholmBrowserBootstrap = {};</script></head><body></body></html>`)
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{}
|
||||
|
||||
got := injectBootstrap(html, state, cfg)
|
||||
|
||||
if strings.Count(string(got), "StockholmBrowserBootstrap") != 1 {
|
||||
t.Error("expected bootstrap not to be injected a second time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectBootstrap_NoHeadTag_ReturnsUnchanged(t *testing.T) {
|
||||
html := []byte(`<html><body>no head here</body></html>`)
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{}
|
||||
|
||||
got := injectBootstrap(html, state, cfg)
|
||||
|
||||
if string(got) != string(html) {
|
||||
t.Error("expected html to be returned unchanged when </head> is absent")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- isBootstrapTarget ----
|
||||
|
||||
func TestIsBootstrapTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"index.html", true},
|
||||
{"INDEX.HTML", true},
|
||||
{"setup/index.html", true},
|
||||
{"SETUP/INDEX.HTML", true},
|
||||
{"js/app.js", false},
|
||||
{"css/main.css", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := isBootstrapTarget(tc.path); got != tc.want {
|
||||
t.Errorf("isBootstrapTarget(%q) = %v, want %v", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- resolveStaticFile ----
|
||||
|
||||
func TestResolveStaticFile_Normal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "app.js"), []byte("js"), 0644)
|
||||
|
||||
file, rel, err := resolveStaticFile("/app.js", dir)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(file, "app.js") {
|
||||
t.Errorf("expected file path to end with app.js, got %q", file)
|
||||
}
|
||||
|
||||
if rel != "app.js" {
|
||||
t.Errorf("expected rel = %q, got %q", "app.js", rel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStaticFile_RootMapsToIndexHTML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html>"), 0644)
|
||||
|
||||
file, rel, err := resolveStaticFile("/", dir)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(file, "index.html") {
|
||||
t.Errorf("expected file path to end with index.html, got %q", file)
|
||||
}
|
||||
|
||||
if rel != "index.html" {
|
||||
t.Errorf("expected rel = %q, got %q", "index.html", rel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStaticFile_DirectoryMapsToIndexHTML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
subDir := filepath.Join(dir, "setup")
|
||||
_ = os.MkdirAll(subDir, 0755)
|
||||
_ = os.WriteFile(filepath.Join(subDir, "index.html"), []byte("<html>"), 0644)
|
||||
|
||||
file, rel, err := resolveStaticFile("/setup", dir)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(file, filepath.Join("setup", "index.html")) {
|
||||
t.Errorf("expected file path to end with setup/index.html, got %q", file)
|
||||
}
|
||||
|
||||
if rel != "setup/index.html" {
|
||||
t.Errorf("expected rel = %q, got %q", "setup/index.html", rel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStaticFile_PathTraversalRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
_, _, err := resolveStaticFile("/../../../etc/passwd", dir)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ServeStatic integration ----
|
||||
|
||||
func TestServeStatic_ServesHTMLWithBootstrap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "index.html"),
|
||||
[]byte(`<html><head></head><body></body></html>`), 0644)
|
||||
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{AppVersion: "27.0"}
|
||||
backendCfg := &BackendConfig{}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ServeStatic(rec, req, dir, backendCfg, state, cfg)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "StockholmBrowserBootstrap") {
|
||||
t.Error("expected bootstrap to be injected in served HTML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStatic_NotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{}
|
||||
backendCfg := &BackendConfig{}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/nonexistent.js", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ServeStatic(rec, req, dir, backendCfg, state, cfg)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStatic_HeadReturnsNoBody(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "index.html"),
|
||||
[]byte(`<html><head></head><body></body></html>`), 0644)
|
||||
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{}
|
||||
backendCfg := &BackendConfig{}
|
||||
|
||||
req := httptest.NewRequest(http.MethodHead, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ServeStatic(rec, req, dir, backendCfg, state, cfg)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Errorf("expected empty body for HEAD, got %d bytes", rec.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStatic_FrontendLoggingCookieSet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "index.html"),
|
||||
[]byte(`<html><head></head><body></body></html>`), 0644)
|
||||
|
||||
state := NewNativeState(t.TempDir())
|
||||
cfg := &Config{}
|
||||
backendCfg := &BackendConfig{FrontendLoggingLevel: 2}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ServeStatic(rec, req, dir, backendCfg, state, cfg)
|
||||
|
||||
cookie := rec.Header().Get("Set-Cookie")
|
||||
if !strings.Contains(cookie, "stockholmFrontendLoggingLevel=2") {
|
||||
t.Errorf("expected logging cookie with level 2, got %q", cookie)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package stockholm
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// kiloDefaultValue is the published default for the Stockholm "kilo"
|
||||
// constant, carried over from the upstream krahl/soundcork-stockholm-app
|
||||
// project (BackendApplication.java). Not a secret — this is the exact
|
||||
// value the Stockholm JS expects to read via getConstant("kilo") when
|
||||
// nothing else has stored a different one. Seeded into NativeState on
|
||||
// first run; also returned by the bridge as a fallback if the state
|
||||
// entry is missing.
|
||||
const kiloDefaultValue = "a7928d7b43dcd49f0af31e5aeed26458"
|
||||
|
||||
func randomHexUUID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,15 @@ import (
|
||||
// (AUTHENTICATION_SPOTIFY_TOKEN = 4). Both Spotify and Amazon use this value.
|
||||
const AuthTypeOAuthToken uint64 = 4
|
||||
|
||||
// ErrAddUserNoOp signals a benign 404-with-empty-body reply from the speaker's
|
||||
// ?action=addUser endpoint. SoundTouch firmware uses that exact response shape
|
||||
// to mean "no transition required" — typically because the requested
|
||||
// activeUser is already the active one. It is NOT a credential or transport
|
||||
// failure; the speaker silently kept its current state. Callers that have
|
||||
// already written the authoritative source record to marge (the path
|
||||
// presets/playback actually go through) should treat this as success.
|
||||
var ErrAddUserNoOp = errors.New("zeroconf: addUser no-op (speaker already in target state)")
|
||||
|
||||
// dhPrimeBytes is the 768-bit MODP Group 1 prime from RFC 2409 §6.1.
|
||||
var dhPrimeBytes = []byte{
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
@@ -333,12 +344,56 @@ func PushCredentials(zcBaseURL, username, accessToken string) error {
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if isAddUserNoOp(resp.StatusCode, body) {
|
||||
logAddUserNoOp("DH", base, username, resp)
|
||||
|
||||
return ErrAddUserNoOp
|
||||
}
|
||||
|
||||
logAddUserFailure("DH", base, username, resp, body)
|
||||
|
||||
return fmt.Errorf("pushCredentials: addUser status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAddUserNoOp recognises the narrow firmware pattern (status 404, empty body)
|
||||
// that signals "no transition required". Anything else — including 404 with a
|
||||
// body, or any other non-2xx — falls through to the real failure path so we
|
||||
// don't silently swallow genuine errors.
|
||||
func isAddUserNoOp(status int, body []byte) bool {
|
||||
return status == http.StatusNotFound && len(bytes.TrimSpace(body)) == 0
|
||||
}
|
||||
|
||||
// logAddUserNoOp emits a single line marking the benign no-op explicitly —
|
||||
// kept visible (not Debug-level) so the operator can correlate it with priming
|
||||
// runs, but worded so it's clearly not a failure.
|
||||
func logAddUserNoOp(path string, base *url.URL, username string, resp *http.Response) {
|
||||
log.Printf("[ZeroConf] addUser produced expected no-op via %s path (speaker already has activeUser=%q or equivalent state): url=%s status=%d body=<empty> — marge source registration is authoritative for preset/playback",
|
||||
path, username, withAction(base, "addUser"), resp.StatusCode)
|
||||
}
|
||||
|
||||
// logAddUserFailure emits a single diagnostic line capturing what the speaker
|
||||
// said about an `?action=addUser` rejection. Bose firmware often returns 4xx
|
||||
// with an empty body, so the headers (libspotify version, content-type,
|
||||
// content-length) are the only clue about whether the speaker refused the
|
||||
// transition, the credential, or the action entirely. Kept verbose on purpose —
|
||||
// these failures are rare and worth grepping for.
|
||||
func logAddUserFailure(path string, base *url.URL, username string, resp *http.Response, body []byte) {
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
cl := resp.Header.Get("Content-Length")
|
||||
server := resp.Header.Get("Server")
|
||||
|
||||
bodySummary := strings.TrimSpace(string(body))
|
||||
if bodySummary == "" {
|
||||
bodySummary = "<empty>"
|
||||
}
|
||||
|
||||
log.Printf("[ZeroConf] addUser rejected via %s path: url=%s userName=%q status=%d server=%q content-type=%q content-length=%q body=%q",
|
||||
path, withAction(base, "addUser"), username, resp.StatusCode, server, ct, cl, bodySummary)
|
||||
}
|
||||
|
||||
// pushSimplifiedToken is the fallback for firmware that does not support DH
|
||||
// key exchange. It sends the raw OAuth access token directly as the blob.
|
||||
func pushSimplifiedToken(zcBaseURL, username, accessToken string) error {
|
||||
@@ -364,6 +419,14 @@ func pushSimplifiedToken(zcBaseURL, username, accessToken string) error {
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if isAddUserNoOp(resp.StatusCode, body) {
|
||||
logAddUserNoOp("simplified", base, username, resp)
|
||||
|
||||
return ErrAddUserNoOp
|
||||
}
|
||||
|
||||
logAddUserFailure("simplified", base, username, resp, body)
|
||||
|
||||
return fmt.Errorf("pushSimplifiedToken: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package zeroconf
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -363,3 +364,112 @@ func TestValidateZcBaseURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushCredentials_AddUserNoOp covers the firmware quirk we observed in
|
||||
// production: ?action=addUser sometimes returns 404 with an empty body when
|
||||
// the speaker already has the requested user as its active one. That is NOT a
|
||||
// failure — the speaker silently kept its state. PushCredentials must signal
|
||||
// this via ErrAddUserNoOp so the watchdog can demote it from "Failed to prime"
|
||||
// to a benign success.
|
||||
func TestPushCredentials_AddUserNoOp(t *testing.T) {
|
||||
_, speakerPublicBytes, err := GenerateDHKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("speaker keygen: %v", err)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": 101,
|
||||
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
|
||||
})
|
||||
case "addUser":
|
||||
// Firmware no-op: 404 + empty body, no Server / Content-Type header.
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err = PushCredentials(srv.URL+"/zc", "gesellix", "fresh-access-token")
|
||||
if !errors.Is(err, ErrAddUserNoOp) {
|
||||
t.Fatalf("PushCredentials: got %v, want ErrAddUserNoOp", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushCredentials_AddUserNoOpInSimplifiedPath asserts the same narrow
|
||||
// pattern is recognised on the simplified-token fallback (firmware that
|
||||
// 404s getInfo entirely).
|
||||
func TestPushCredentials_AddUserNoOpInSimplifiedPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusNotFound) // empty body
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := PushCredentials(srv.URL+"/zc", "gesellix", "raw-access-token")
|
||||
if !errors.Is(err, ErrAddUserNoOp) {
|
||||
t.Fatalf("PushCredentials (simplified path): got %v, want ErrAddUserNoOp", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushCredentials_AddUserRealError_NotMisclassified guards the narrowness
|
||||
// of isAddUserNoOp: a 404 *with* a body (or any non-404 error) must still
|
||||
// surface as a regular error, not the benign sentinel. Otherwise we'd silently
|
||||
// swallow genuine credential rejections that happen to come back as 4xx.
|
||||
func TestPushCredentials_AddUserRealError_NotMisclassified(t *testing.T) {
|
||||
_, speakerPublicBytes, err := GenerateDHKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("speaker keygen: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"404 with body should NOT be no-op", http.StatusNotFound, "spotifyError=12 invalid_token"},
|
||||
{"400 empty body should NOT be no-op", http.StatusBadRequest, ""},
|
||||
{"500 empty body should NOT be no-op", http.StatusInternalServerError, ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": 101,
|
||||
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
|
||||
})
|
||||
case "addUser":
|
||||
w.WriteHeader(tc.status)
|
||||
if tc.body != "" {
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := PushCredentials(srv.URL+"/zc", "gesellix", "fresh-access-token")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if errors.Is(err, ErrAddUserNoOp) {
|
||||
t.Errorf("got ErrAddUserNoOp, want a real failure for status=%d body=%q", tc.status, tc.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch Stockholm bridge JS files to use window.__stockholmBase for API paths.
|
||||
|
||||
Usage: patch-stockholm-bridge.py <file> [<file> ...]
|
||||
|
||||
Applied once by `make prepare-stockholm`. Idempotent — already-patched files
|
||||
are left unchanged.
|
||||
"""
|
||||
import sys
|
||||
|
||||
REPLACEMENTS = [
|
||||
(
|
||||
'xhr.open("POST", "/api/native/appSend"',
|
||||
'xhr.open("POST", (window.__stockholmBase||"") + "/api/native/appSend"',
|
||||
),
|
||||
(
|
||||
'xhr.open("GET", "/api/native/runQueue',
|
||||
'xhr.open("GET", (window.__stockholmBase||"") + "/api/native/runQueue',
|
||||
),
|
||||
(
|
||||
'var proxyPath = "/api/http-proxy";',
|
||||
'var proxyPath = (window.__stockholmBase||"") + "/api/http-proxy";',
|
||||
),
|
||||
(
|
||||
# The standalone /api/http-proxy declaration in browser_http_proxy.js
|
||||
# uses an UPPERCASE constant name. Same shape as above, different
|
||||
# identifier — keep both replacements; the lowercase one applies to
|
||||
# app_comm.js, the uppercase one to browser_http_proxy.js.
|
||||
'var PROXY_PATH = "/api/http-proxy";',
|
||||
'var PROXY_PATH = (window.__stockholmBase||"") + "/api/http-proxy";',
|
||||
),
|
||||
(
|
||||
# browser_http_proxy.js's IIFE evaluates PROXY_PATH at script-load
|
||||
# time, but the injected bootstrap that defines window.__stockholmBase
|
||||
# is placed just before </head> — i.e. after the <script src=…> tags
|
||||
# for the bridge files. So PROXY_PATH would always fall back to the
|
||||
# unprefixed "/api/http-proxy", failing under STOCKHOLM_BASE_PATH.
|
||||
# Inline a lazy expression at the use site so it reads __stockholmBase
|
||||
# at call time, when bootstrap has finished. The var declaration above
|
||||
# remains patched but becomes dead code.
|
||||
'return PROXY_PATH + "?url=" + encodeURIComponent(target.href);',
|
||||
'return (window.__stockholmBase||"") + "/api/http-proxy?url=" + encodeURIComponent(target.href);',
|
||||
),
|
||||
(
|
||||
'return new URL(url, window.location.origin + "/").href;',
|
||||
'return new URL(url, window.location.origin + (window.__stockholmBase || "") + "/").href;',
|
||||
),
|
||||
]
|
||||
|
||||
for path in sys.argv[1:]:
|
||||
try:
|
||||
original = open(path).read()
|
||||
patched = original
|
||||
for old, new in REPLACEMENTS:
|
||||
patched = patched.replace(old, new)
|
||||
if patched != original:
|
||||
open(path, "w").write(patched)
|
||||
except FileNotFoundError:
|
||||
print(f"warning: {path} not found, skipping", file=sys.stderr)
|
||||
@@ -1,4 +1,8 @@
|
||||
### GET /bmx/tunein/v1/playback/station/_station_
|
||||
### GET /bmx/tunein/v1/playback/station/_station_ (no Authorization)
|
||||
### Auth gate temporarily disabled — see handlers_bmx.go (writeBMXUnauthorized
|
||||
### is kept as the future-restore point). When the gate is re-enabled,
|
||||
### swap the 200/audio assertions below back to the 401/Unauthorized ones
|
||||
### that were here historically.
|
||||
GET {{host}}/bmx/tunein/v1/playback/station/_station_
|
||||
Accept: */*
|
||||
Accept-Language: en
|
||||
@@ -7,13 +11,13 @@ X-Bmx-Device-Id: bmx-device-id-dummy
|
||||
User-Agent: Bose_Lisa/27.0.6
|
||||
|
||||
> {%
|
||||
client.test("Response is 401 Unauthorized", function() {
|
||||
client.assert(response.status === 401, "Response status is not 401");
|
||||
client.test("Response is 200 OK (auth gate temporarily disabled)", function() {
|
||||
client.assert(response.status === 200, "Response status is not 200");
|
||||
});
|
||||
|
||||
client.test("Response body contains 'Unauthorized'", function() {
|
||||
client.assert(response.body.includes("401 Unauthorized"), "Response body does not contain '401 Unauthorized'");
|
||||
client.assert(response.body.includes("No access token found."), "Response body does not contain 'No access token found.'");
|
||||
client.test("Response contains audio information", function() {
|
||||
client.assert(response.body.hasOwnProperty("audio"), "Response missing 'audio'");
|
||||
client.assert(response.body.audio.hasOwnProperty("streamUrl"), "Response missing 'streamUrl'");
|
||||
});
|
||||
%}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user