mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
@@ -112,7 +112,7 @@ jobs:
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
output_name="${output_name}.exe"
|
||||
fi
|
||||
go build -o "$output_name" ./cmd/soundtouch-cli
|
||||
go build -trimpath -ldflags="-s -w" -o "$output_name" ./cmd/soundtouch-cli
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
|
||||
- name: Test CLI build and help
|
||||
run: |
|
||||
go build -o soundtouch-cli ./cmd/soundtouch-cli
|
||||
go build -trimpath -ldflags="-s -w" -o soundtouch-cli ./cmd/soundtouch-cli
|
||||
./soundtouch-cli -help
|
||||
|
||||
- name: Test library imports
|
||||
|
||||
@@ -151,6 +151,7 @@ jobs:
|
||||
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
|
||||
|
||||
if ! go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "$OUTPUT_NAME" \
|
||||
"$CMD_PATH"; then
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder
|
||||
|
||||
# Declare automatic platform ARGs to make them available in build stage
|
||||
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
|
||||
|
||||
@@ -28,8 +28,8 @@ BACKUP_NAME=soundtouch-backup
|
||||
BACKUP_PATH=./cmd/$(BACKUP_NAME)
|
||||
BUILD_DIR=./build
|
||||
|
||||
# Version info
|
||||
# No ldflags needed - using debug.BuildInfo since Go 1.18
|
||||
# Build flags: strip debug info/DWARF for smaller binaries, remove local paths for reproducibility
|
||||
BUILDFLAGS=-trimpath -ldflags="-s -w"
|
||||
|
||||
all: check build
|
||||
|
||||
@@ -38,78 +38,85 @@ build: build-cli build-service build-web build-examples build-favicon-gen build-
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
|
||||
|
||||
build-service:
|
||||
@echo "Building $(SERVICE_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
|
||||
build-web:
|
||||
@echo "Building $(WEB_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
|
||||
|
||||
build-examples:
|
||||
@echo "Building $(EXAMPLE_MDNS_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
|
||||
@echo "Building $(EXAMPLE_UPNP_NAME)..."
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
|
||||
@echo "Building $(SCANNER_NAME)..."
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
|
||||
build-favicon-gen:
|
||||
@echo "Building $(FAVICON_GEN_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
|
||||
|
||||
build-backup:
|
||||
@echo "Building $(BACKUP_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME) $(BACKUP_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME) $(BACKUP_PATH)
|
||||
|
||||
build-all: build-linux build-darwin build-windows build-examples-all
|
||||
build-all: build-linux build-linux-armv7 build-darwin build-windows build-examples-all
|
||||
|
||||
build-linux:
|
||||
@echo "Building for Linux..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-amd64 $(BACKUP_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-amd64 $(BACKUP_PATH)
|
||||
|
||||
build-linux-armv7:
|
||||
@echo "Building for Linux ARMv7 (CGO_ENABLED=0 for kernel 3.14+ compatibility)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-armv7 $(SERVICE_PATH)
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-armv7 $(BACKUP_PATH)
|
||||
|
||||
build-darwin:
|
||||
@echo "Building for macOS..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-amd64 $(BACKUP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-arm64 $(BACKUP_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-amd64 $(BACKUP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-arm64 $(BACKUP_PATH)
|
||||
|
||||
build-windows:
|
||||
@echo "Building for Windows..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-windows-amd64.exe $(BACKUP_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-windows-amd64.exe $(BACKUP_PATH)
|
||||
|
||||
build-examples-all:
|
||||
@echo "Building examples for all platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
|
||||
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
@@ -338,6 +345,7 @@ help:
|
||||
@echo " build-favicon-gen - Build the favicon generator"
|
||||
@echo " build-examples - Build only the example programs"
|
||||
@echo " build-all - Build for all platforms"
|
||||
@echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)"
|
||||
@echo " test - Run tests"
|
||||
@echo " test-coverage - Run tests with coverage report"
|
||||
@echo " check - Run fmt, vet, and tests"
|
||||
|
||||
@@ -240,6 +240,12 @@ func main() {
|
||||
Value: true,
|
||||
EnvVars: []string{"RECORD_INTERACTIONS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "discovery-enabled",
|
||||
Usage: "Enable periodic device discovery",
|
||||
Value: true,
|
||||
EnvVars: []string{"DISCOVERY_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "discovery-interval",
|
||||
Usage: "Device discovery interval",
|
||||
@@ -387,7 +393,7 @@ func main() {
|
||||
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
|
||||
|
||||
cm := initCertificateManager(config.dataDir)
|
||||
cm := initCertificateManager(config.dataDir, config.hostname)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
sm.MgmtUsername = config.mgmtUsername
|
||||
sm.MgmtPassword = config.mgmtPassword
|
||||
@@ -395,7 +401,7 @@ func main() {
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetVersionInfo(version, commit, date, repoURL)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
|
||||
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
|
||||
server.SetInternalPaths(persisted.InternalPaths)
|
||||
@@ -460,20 +466,25 @@ func main() {
|
||||
|
||||
initializeDefaultSources(ds)
|
||||
|
||||
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to setup TLS: %v", err)
|
||||
}
|
||||
|
||||
startDeviceDiscovery(server)
|
||||
|
||||
r := setupRouter(server)
|
||||
|
||||
log.Printf("Go service starting on %s", config.serverURL)
|
||||
|
||||
if tlsConfig != nil {
|
||||
// TLS cert generation can be slow on constrained hardware; run it in the
|
||||
// background so the HTTP server is available immediately.
|
||||
log.Printf("HTTPS setup running in background; %s will be available shortly", config.httpsServerURL)
|
||||
|
||||
go func() {
|
||||
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to setup TLS: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
|
||||
}
|
||||
}()
|
||||
|
||||
return http.ListenAndServe(config.addr, r)
|
||||
},
|
||||
@@ -507,6 +518,7 @@ type serviceConfig struct {
|
||||
bindAddr string
|
||||
addr string
|
||||
dataDir string
|
||||
hostname string
|
||||
serverURL string
|
||||
httpsServerURL string
|
||||
httpsAddr string
|
||||
@@ -520,6 +532,7 @@ type serviceConfig struct {
|
||||
mirrorEndpoints []string
|
||||
skipMirrorEndpoints []string
|
||||
internalPaths []string
|
||||
discoveryEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
@@ -584,6 +597,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
dnsUpstream := c.String("dns-upstream")
|
||||
dnsBind := c.String("dns-bind")
|
||||
|
||||
discoveryEnabled := c.Bool("discovery-enabled")
|
||||
discoveryIntervalStr := c.String("discovery-interval")
|
||||
|
||||
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
|
||||
@@ -618,6 +632,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
dataDir: dataDir,
|
||||
hostname: hostname,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsAddr: httpsAddr,
|
||||
@@ -631,6 +646,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
mirrorEndpoints: mirrorEndpoints,
|
||||
skipMirrorEndpoints: skipMirrorEndpoints,
|
||||
internalPaths: internalPaths,
|
||||
discoveryEnabled: discoveryEnabled,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
@@ -713,6 +729,7 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
config.httpsServerURL = persisted.HTTPServerURL
|
||||
}
|
||||
|
||||
config.discoveryEnabled = persisted.DiscoveryEnabled
|
||||
if persisted.DiscoveryInterval != "" {
|
||||
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
|
||||
config.discoveryInterval = d
|
||||
@@ -779,8 +796,8 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryEnabled: config.discoveryEnabled,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DiscoveryEnabled: true,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
@@ -809,8 +826,10 @@ func initDataStore(dataDir string) *datastore.DataStore {
|
||||
return ds
|
||||
}
|
||||
|
||||
func initCertificateManager(dataDir string) *certmanager.CertificateManager {
|
||||
func initCertificateManager(dataDir, hostname string) *certmanager.CertificateManager {
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))
|
||||
|
||||
cm.CommonName = hostname
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
log.Printf("Warning: Failed to ensure CA: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,648 +0,0 @@
|
||||
// Package handlers contains HTTP handlers for the SoundTouch web UI.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebApp holds the application state and dependencies
|
||||
type WebApp struct {
|
||||
Devices map[string]*webtypes.DeviceConnection
|
||||
Upgrader websocket.Upgrader
|
||||
WSClients map[*websocket.Conn]bool
|
||||
WSMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewWebApp creates a new WebApp instance for SPA mode
|
||||
func NewWebApp() *WebApp {
|
||||
return &WebApp{
|
||||
Devices: make(map[string]*webtypes.DeviceConnection),
|
||||
WSClients: make(map[*websocket.Conn]bool),
|
||||
Upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { 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,
|
||||
}
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevice returns a specific device as JSON
|
||||
func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update device status to get fresh power state
|
||||
app.UpdateDeviceStatus(deviceID, device)
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIControl handles device control commands
|
||||
func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
action := chi.URLParam(r, "action")
|
||||
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
app.handleControlAction(w, r, action, device)
|
||||
}
|
||||
|
||||
// handleControlAction processes different control actions
|
||||
func (app *WebApp) handleControlAction(w http.ResponseWriter, r *http.Request, action string, device *webtypes.DeviceConnection) {
|
||||
switch action {
|
||||
case "play":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Play()
|
||||
app.sendControlResponse(w, err, "Started playback")
|
||||
case "pause":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Pause()
|
||||
app.sendControlResponse(w, err, "Paused playback")
|
||||
case "stop":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Stop()
|
||||
app.sendControlResponse(w, err, "Stopped playback")
|
||||
case "next":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.NextTrack()
|
||||
app.sendControlResponse(w, err, "Next track")
|
||||
case "previous":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.PrevTrack()
|
||||
app.sendControlResponse(w, err, "Previous track")
|
||||
case "volume":
|
||||
app.handleVolumeControl(w, r, device)
|
||||
case "mute":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SendKey(models.KeyMute)
|
||||
app.sendControlResponse(w, err, "Toggled mute")
|
||||
case "preset":
|
||||
app.handlePresetControl(w, r, device)
|
||||
case "bass":
|
||||
app.handleBassControl(w, r, device)
|
||||
case "source":
|
||||
app.handleSourceControl(w, r, device)
|
||||
default:
|
||||
app.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVolumeControl processes volume control requests
|
||||
func (app *WebApp) handleVolumeControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for volume control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var volumeReq webtypes.VolumeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&volumeReq); err != nil {
|
||||
app.sendError(w, "Invalid volume data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if volumeReq.Level < 0 || volumeReq.Level > 100 {
|
||||
app.sendError(w, "Volume must be between 0 and 100", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetVolume(volumeReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeReq.Level))
|
||||
}
|
||||
|
||||
// handlePresetControl processes preset control requests
|
||||
func (app *WebApp) handlePresetControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
presetParam := r.URL.Query().Get("id")
|
||||
if presetParam == "" {
|
||||
app.sendError(w, "Preset ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
presetID, err := strconv.Atoi(presetParam)
|
||||
if err != nil {
|
||||
app.sendError(w, "Invalid preset ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = device.Client.SelectPreset(presetID)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected preset %d", presetID))
|
||||
}
|
||||
|
||||
// handleBassControl processes bass control requests
|
||||
func (app *WebApp) handleBassControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for bass control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var bassReq webtypes.BassRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&bassReq); err != nil {
|
||||
app.sendError(w, "Invalid bass data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if bassReq.Level < -9 || bassReq.Level > 9 {
|
||||
app.sendError(w, "Bass must be between -9 and 9", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetBass(bassReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Bass set to %d", bassReq.Level))
|
||||
}
|
||||
|
||||
// handleSourceControl processes source control requests
|
||||
func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
sourceParam := r.URL.Query().Get("name")
|
||||
if sourceParam == "" {
|
||||
app.sendError(w, "Source name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SelectSource(sourceParam, "")
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected source %s", sourceParam))
|
||||
}
|
||||
|
||||
// sendControlResponse sends a control command response
|
||||
func (app *WebApp) sendControlResponse(w http.ResponseWriter, err error, successMessage string) {
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": successMessage},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// sendError sends an error response
|
||||
func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: false,
|
||||
Error: message,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceKey handles sending key commands to devices
|
||||
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]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err := device.Client.SendKey(key)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Sent key command: %s", key))
|
||||
}
|
||||
|
||||
// HandleDirectVolumeControl handles direct volume setting via URL parameter
|
||||
func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
volumeLevel, err := strconv.Atoi(chi.URLParam(r, "volume"))
|
||||
if err != nil || volumeLevel < 0 || volumeLevel > 100 {
|
||||
app.sendError(w, "Invalid volume level (0-100)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err = device.Client.SetVolume(volumeLevel)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeLevel))
|
||||
}
|
||||
|
||||
// HandleDevicePower handles power toggle commands for devices
|
||||
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Send POWER key command to toggle device power
|
||||
err := device.Client.SendKey("POWER")
|
||||
app.sendControlResponse(w, err, "Power toggle command sent")
|
||||
}
|
||||
|
||||
// HandleDevicePowerStatus handles lightweight power status check
|
||||
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Quick power status check by getting now playing
|
||||
nowPlaying, err := device.Client.GetNowPlaying()
|
||||
if err != nil {
|
||||
app.sendControlResponse(w, err, "Failed to get power status")
|
||||
return
|
||||
}
|
||||
|
||||
isPoweredOn := nowPlaying != nil && nowPlaying.Source != "STANDBY"
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"deviceId": deviceID,
|
||||
"isPoweredOn": isPoweredOn,
|
||||
"source": nowPlaying.Source,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDeviceList sends updated device list to all connected WebSocket clients
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send device update to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDiscoveryStatus sends discovery progress updates to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "discovery_status",
|
||||
Data: map[string]interface{}{
|
||||
"status": status,
|
||||
"deviceCount": deviceCount,
|
||||
},
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send discovery status to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch handles TuneIn search requests, proxying directly to the bmx package.
|
||||
func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmxpkg.TuneInSearch(query)
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
|
||||
// Supported path suffixes (relative to /api/tunein/navigate):
|
||||
// - (empty) → top-level browse
|
||||
// - /{encodedURI} → browse the given TuneIn URI
|
||||
// - /sub/{n}/{encodedURI} → single subsection
|
||||
// - /profiles/{type}/{id}/{encodedURI} → artist/program profile
|
||||
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
wildcard := chi.URLParam(r, "*")
|
||||
|
||||
var (
|
||||
resp interface{}
|
||||
err error
|
||||
)
|
||||
|
||||
if wildcard == "" {
|
||||
resp, err = bmxpkg.TuneInNavigate("", nil)
|
||||
} else {
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
pfx := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch pfx {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest, nil)
|
||||
} else {
|
||||
n, parseErr := strconv.Atoi(rest[:secondSlash])
|
||||
if parseErr != nil {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
}
|
||||
}
|
||||
case "profiles":
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigateProfile(parts[2])
|
||||
}
|
||||
default:
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
|
||||
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Location string `json:"location"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ContainerArt string `json:"containerArt"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
app.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Location == "" {
|
||||
app.sendError(w, "location is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
itemType := req.Type
|
||||
if itemType == "" {
|
||||
itemType = "stationurl"
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: itemType,
|
||||
Location: req.Location,
|
||||
ItemName: req.Name,
|
||||
IsPresetable: true,
|
||||
ContainerArt: req.ContainerArt,
|
||||
}
|
||||
|
||||
if err := device.Client.SelectContentItem(contentItem); err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]string{"message": "Playing " + req.Name}}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
+4
-164
@@ -2,26 +2,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-web",
|
||||
@@ -49,36 +38,10 @@ func main() {
|
||||
addr = bindAddr + ":" + port
|
||||
}
|
||||
|
||||
// Create web app without templates (SPA mode)
|
||||
webApp := handlers.NewWebApp()
|
||||
webApp := soundtouchweb.New()
|
||||
|
||||
// Initialize discovery service
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
log.Printf("Failed to load config: %v, using defaults", err)
|
||||
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
cfg.DiscoveryTimeout = 10 * time.Second
|
||||
cfg.CacheEnabled = true
|
||||
|
||||
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
// Discover devices on startup
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("starting", len(webApp.Devices))
|
||||
|
||||
discoverDevices(ctx, webApp, discoveryService)
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("completed", len(webApp.Devices))
|
||||
webApp.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
r := setupRoutes(webApp, discoveryService)
|
||||
r := chi.NewRouter()
|
||||
webApp.Mount(r)
|
||||
|
||||
log.Printf("SoundTouch Web UI starting on http://%s", addr)
|
||||
|
||||
@@ -90,126 +53,3 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Static assets (embedded in binary)
|
||||
subFS, _ := fs.Sub(staticFS, "static")
|
||||
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
|
||||
|
||||
// Serve index.html for SPA routes
|
||||
serveIndex := func(w http.ResponseWriter, _ *http.Request) {
|
||||
data, _ := staticFS.ReadFile("static/index.html")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// WebSocket endpoint
|
||||
r.Get("/ws", app.HandleWebSocket)
|
||||
|
||||
// API endpoints
|
||||
r.Get("/api/devices", app.HandleAPIDevices)
|
||||
r.Get("/api/device/{id}", app.HandleAPIDevice)
|
||||
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
|
||||
app.HandleAPIDiscover(w, r)
|
||||
// Trigger discovery
|
||||
//nolint:contextcheck // Context is created within goroutine
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Broadcast discovery start
|
||||
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
|
||||
|
||||
discoverDevices(ctx, app, discoveryService)
|
||||
|
||||
// Broadcast discovery completion and updated device list
|
||||
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
|
||||
app.BroadcastDeviceList()
|
||||
}()
|
||||
})
|
||||
|
||||
// Device control endpoints (GET for most actions, POST for volume/bass)
|
||||
r.Get("/api/control/{id}/{action}", app.HandleAPIControl)
|
||||
r.Post("/api/control/{id}/{action}", app.HandleAPIControl)
|
||||
|
||||
// TuneIn browse, search, and playback
|
||||
r.Get("/api/tunein/search", app.HandleTuneInSearch)
|
||||
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
|
||||
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
|
||||
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
|
||||
|
||||
// Enhanced device control endpoints
|
||||
r.Post("/api/device-key/{id}/{key}", app.HandleDeviceKey)
|
||||
r.Post("/api/device-volume/{id}/{volume}", app.HandleDirectVolumeControl)
|
||||
r.Post("/api/device-power/{id}", app.HandleDevicePower)
|
||||
r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus)
|
||||
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
|
||||
|
||||
// SPA routes - serve index.html for client-side routing
|
||||
r.Get("/", serveIndex)
|
||||
r.Get("/devices", serveIndex)
|
||||
r.Get("/device/*", serveIndex)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
|
||||
log.Println("Starting device discovery...")
|
||||
|
||||
devices, err := discoveryService.DiscoverDevices(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Discovery failed: %v", err)
|
||||
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -55,22 +55,19 @@ func TestSPARouting(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Simulate SPA routing handler
|
||||
spaHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
// If it's an API route, let it pass through
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/ws") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the SPA index.html content (simulated)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
<title>SoundTouch Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">SPA Content</div>
|
||||
@@ -100,7 +97,7 @@ func TestSPARouting(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAPIEndpoints(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -127,7 +124,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
name: "device API with ID",
|
||||
path: "/api/device/test-device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound, // Device won't exist in test
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedJSON: true,
|
||||
},
|
||||
}
|
||||
@@ -160,7 +157,6 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
// Validate JSON response structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
@@ -171,7 +167,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAPIResponseFormat(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -183,7 +179,6 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
t.Fatalf("Failed to decode JSON response: %v", err)
|
||||
}
|
||||
|
||||
// Check API response structure
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success=true, got success=%v", response.Success)
|
||||
}
|
||||
@@ -192,7 +187,6 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
t.Errorf("Expected data field to be present")
|
||||
}
|
||||
|
||||
// Data should be an empty map for no devices
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Expected data to be a map, got %T", response.Data)
|
||||
@@ -204,7 +198,7 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestControlAPIValidation(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -249,7 +243,6 @@ 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"},
|
||||
@@ -278,7 +271,6 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
t.Errorf("Test %s: Expected status %d, got %d. Response: %s", tt.name, tt.expectedStatus, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Validate error response format
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
@@ -301,9 +293,8 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebSocketUpgrade(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
// Test WebSocket upgrade request
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Connection", "upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
@@ -312,16 +303,11 @@ func TestWebSocketUpgrade(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// The actual WebSocket upgrade will fail in test environment,
|
||||
// but we can check that the handler exists and accepts the request
|
||||
app.HandleWebSocket(w, req)
|
||||
|
||||
// In a real test environment, this would fail with a websocket upgrade error
|
||||
// We're just checking the handler doesn't panic and processes the request
|
||||
}
|
||||
|
||||
func TestJSONAPIConsistency(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
endpoints := []string{
|
||||
"/api/devices",
|
||||
@@ -344,19 +330,16 @@ func TestJSONAPIConsistency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// All API endpoints should return JSON
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Endpoint %s should return JSON, got %s", endpoint, contentType)
|
||||
}
|
||||
|
||||
// All responses should follow APIResponse structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Endpoint %s returned invalid JSON: %v", endpoint, err)
|
||||
}
|
||||
|
||||
// Response should have either data or error
|
||||
if response.Success && response.Data == nil {
|
||||
t.Errorf("Endpoint %s: success response should have data", endpoint)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,200 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="/static/css/app.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#" onclick="showPage('devices')">
|
||||
<i class="bi bi-speaker"></i>
|
||||
SoundTouch Control
|
||||
</a>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="showPage('devices')"
|
||||
title="Home"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link tunein-nav-link"
|
||||
href="#"
|
||||
onclick="showPage('tunein')"
|
||||
title="TuneIn Browse"
|
||||
>
|
||||
<img
|
||||
src="/static/img/tunein-mono.svg"
|
||||
alt="TuneIn"
|
||||
class="tunein-nav-icon"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="discoverDevices()"
|
||||
title="Discover Devices"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
</a>
|
||||
<button
|
||||
class="theme-toggle nav-link"
|
||||
onclick="toggleTheme()"
|
||||
title="Toggle Dark Mode"
|
||||
>
|
||||
<i id="theme-icon" class="bi bi-moon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<!-- Device List Page -->
|
||||
<div id="devices-page" class="page active">
|
||||
<div
|
||||
class="d-flex justify-content-between align-items-center mb-4"
|
||||
>
|
||||
<h2>Your SoundTouch Devices</h2>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Discover Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="devices-loading" class="loading-spinner"></div>
|
||||
|
||||
<div id="devices-list" class="row">
|
||||
<!-- Device cards will be inserted here by JavaScript -->
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="no-devices"
|
||||
style="display: none"
|
||||
class="text-center py-5"
|
||||
>
|
||||
<i class="bi bi-speaker display-1 text-muted"></i>
|
||||
<h4 class="mt-3">No Devices Found</h4>
|
||||
<p class="text-muted">
|
||||
Click "Discover Devices" to search for SoundTouch
|
||||
speakers on your network.
|
||||
</p>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Start Discovery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TuneIn Browse Page -->
|
||||
<div id="tunein-page" class="page">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><img src="/static/img/tunein-dark.svg" alt="TuneIn" class="tunein-heading-icon me-2" />TuneIn Browse</h2>
|
||||
</div>
|
||||
|
||||
<div class="tunein-search-bar mb-3">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="tunein-search-input"
|
||||
class="form-control"
|
||||
placeholder="Search stations, podcasts..."
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="tuneInSearch(document.getElementById('tunein-search-input').value)"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="tuneInBrowse()"
|
||||
title="Browse top level"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="tunein-breadcrumb" class="mb-3" style="display: none">
|
||||
<!-- filled by JavaScript -->
|
||||
</nav>
|
||||
|
||||
<div id="tunein-results">
|
||||
<!-- filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Control Page -->
|
||||
<div id="device-page" class="page">
|
||||
<div class="back-button">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="showPage('devices')"
|
||||
>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
Back to Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="device-content">
|
||||
<!-- Device control content will be inserted here by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container text-center">
|
||||
<small>
|
||||
SoundTouch Web Control Interface -
|
||||
<a
|
||||
href="https://github.com/gesellix/Bose-SoundTouch"
|
||||
target="_blank"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
Open Source Project
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container"></div>
|
||||
|
||||
<!-- Device picker for TuneIn playback -->
|
||||
<div class="modal fade" id="devicePickerModal" tabindex="-1" aria-labelledby="devicePickerLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title" id="devicePickerLabel">
|
||||
<i class="bi bi-speaker me-2"></i>Play on device
|
||||
</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-2" id="devicePickerList">
|
||||
<!-- device buttons filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Application JavaScript -->
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
accounts/
|
||||
backend/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
- AMAZON_PROFILE_URL=http://amazon-mock:8080/user/profile
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.2-alpine
|
||||
image: golang:1.26.3-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
- soundtouch-test-net
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.2-alpine
|
||||
image: golang:1.26.3-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
|
||||
@@ -28,7 +28,27 @@ soundtouch-service
|
||||
|
||||
The service starts on port 8000. Open `http://localhost:8000` in your browser.
|
||||
|
||||
### Docker (Linux — with host networking for device discovery)
|
||||
### Docker Compose (recommended for home servers and VMs)
|
||||
|
||||
The repository ships a `docker-compose.yml` ready for this use case. Clone or download it, copy the example config, then edit `.env` before starting:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# SOUNDTOUCH_HOSTNAME=192.168.1.100 ← your server's address
|
||||
# SOUNDTOUCH_VERSION=v0.70.0 ← pin to a release tag instead of 'latest'
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`SOUNDTOUCH_HOSTNAME` is the address your speakers will use to reach the service — use a hostname or IP reachable from the speaker, not `localhost`.
|
||||
|
||||
On **Linux** (Debian, Proxmox VE, Raspberry Pi OS, etc.) you can enable host networking for automatic speaker discovery. Uncomment the `network_mode: host` line in `docker-compose.yml` and remove the `ports:` section (they conflict with host networking). Without host networking, add your speakers by IP address in Step 4 instead.
|
||||
|
||||
For local overrides (e.g. switching to `build: .` during development), create a `docker-compose.override.yml` — Docker Compose picks it up automatically and it is not tracked in version control.
|
||||
|
||||
> **Note on `docker-compose.ci.yml`**: this file contains mock services used only for automated integration tests. It is not needed for your own deployment.
|
||||
|
||||
### Docker run (Linux — with host networking for device discovery)
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
@@ -38,7 +58,7 @@ docker run -d \
|
||||
ghcr.io/gesellix/bose-soundtouch:latest
|
||||
```
|
||||
|
||||
### Docker (macOS / Windows — manual device IP required)
|
||||
### Docker run (macOS / Windows — manual device IP required)
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
|
||||
@@ -853,6 +853,85 @@ cat data/accounts/3230304/devices/*/DeviceInfo.xml | grep macAddress
|
||||
|
||||
---
|
||||
|
||||
## 🌐 **Hostname Resolution** {#hostname-resolution}
|
||||
|
||||
### Why the service resolves the hostname from the device
|
||||
|
||||
When you migrate a speaker using the resolv.conf method, the service needs to write a raw IP address into the speaker's network configuration. That IP must be the address the *speaker itself* can reach — which is not necessarily the same address your computer resolves.
|
||||
|
||||
In environments with NAT, split-horizon DNS, or Docker/container networking, `soundtouch.local` (or whatever you set as `SERVER_URL`) may resolve to a different IP depending on who is asking. The service therefore resolves the hostname by running `ping -c 1 <hostname>` over SSH on the speaker and extracting the IP from the output. This is the authoritative result: it is exactly what the speaker would use.
|
||||
|
||||
If that SSH ping fails, migration is aborted. Writing an unresolvable or incorrectly resolved hostname into `aftertouch.resolv.conf` would silently break the speaker's DNS config and prevent it from reaching the service after reboot.
|
||||
|
||||
**The XML migration method is different.** It writes the full URL (e.g. `http://soundtouch.local:8000`) into `SoundTouchSdkPrivateCfg.xml`. The speaker resolves the hostname at connect time, not at migration time. This means migration can proceed even if the hostname is not yet reachable — for example, when the service will be deployed under that hostname but is not running yet. A warning is still shown in the UI so you are aware, but the Confirm Migration button remains enabled.
|
||||
|
||||
### ❌ "Cannot resolve target hostname for migration"
|
||||
|
||||
**Symptoms** (migration log or web UI warning):
|
||||
```
|
||||
cannot resolve target hostname for migration: cannot resolve "soundtouch.local":
|
||||
SSH ping from device failed and service-side DNS lookup also failed
|
||||
```
|
||||
or:
|
||||
```
|
||||
resolved "soundtouch.local" to 192.168.1.100 from service, not from device —
|
||||
result may be wrong if NAT or split-DNS is in use
|
||||
```
|
||||
|
||||
**What this means:**
|
||||
|
||||
The service could not confirm the IP by running `ping` on the speaker via SSH. Either:
|
||||
- the `ping` binary is not available or not in `$PATH` on this firmware, or
|
||||
- the hostname is not resolvable from the speaker's network context.
|
||||
|
||||
**Diagnosis — run manually over SSH:**
|
||||
|
||||
```bash
|
||||
# SSH into the speaker
|
||||
ssh root@<speaker-ip>
|
||||
|
||||
# Try to resolve the service hostname
|
||||
ping -c 1 soundtouch.local
|
||||
# or use the IP directly to verify connectivity
|
||||
ping -c 1 192.168.1.100
|
||||
|
||||
# Check the speaker's current DNS config
|
||||
cat /etc/resolv.conf
|
||||
|
||||
# Check if ping is available
|
||||
which ping
|
||||
busybox ping --help
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
||||
#### 1. Use an IP address as SERVER_URL
|
||||
|
||||
The most reliable fix. If the hostname cannot be resolved from the device, use a raw IP instead. Resolution is skipped entirely when `SERVER_URL` contains an IP.
|
||||
|
||||
```bash
|
||||
# In your .env
|
||||
SERVER_URL=http://192.168.1.100:8000
|
||||
HTTPS_SERVER_URL=https://192.168.1.100:8443
|
||||
```
|
||||
|
||||
HTTPS works correctly with IP addresses — the service certificate includes the IP as a Subject Alternative Name (SAN).
|
||||
|
||||
#### 2. Ensure the hostname resolves on the speaker's network segment
|
||||
|
||||
If you use `soundtouch.local`, verify mDNS is working from another device on the same subnet:
|
||||
|
||||
```bash
|
||||
avahi-resolve -n soundtouch.local # Linux
|
||||
dns-sd -G v4 soundtouch.local # macOS
|
||||
```
|
||||
|
||||
#### 3. Use the XML migration method
|
||||
|
||||
Select the XML method in the migration UI. It writes the full URL and the speaker resolves it at connect time, so hostname resolution is not required during migration. This also allows migrating to a hostname that is not yet live.
|
||||
|
||||
---
|
||||
|
||||
## 🛟 **Getting More Help**
|
||||
|
||||
### Information to Gather
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.57.0
|
||||
require github.com/gesellix/bose-soundtouch v0.71.2
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.57.0
|
||||
require github.com/gesellix/bose-soundtouch v0.71.2
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
|
||||
@@ -783,7 +783,11 @@ func (c *Client) SelectSource(source, sourceAccount string) error {
|
||||
case "BLUETOOTH":
|
||||
contentItem.ItemName = "Bluetooth"
|
||||
case "AUX":
|
||||
contentItem.ItemName = "AUX Input"
|
||||
contentItem.ItemName = "AUX IN"
|
||||
// The speaker rejects AUX with empty sourceAccount as INVALID_SOURCE.
|
||||
if contentItem.SourceAccount == "" {
|
||||
contentItem.SourceAccount = "AUX"
|
||||
}
|
||||
case "TUNEIN":
|
||||
contentItem.ItemName = "TuneIn"
|
||||
case "PANDORA":
|
||||
@@ -818,7 +822,7 @@ func (c *Client) SelectBluetooth() error {
|
||||
return c.SelectSource("BLUETOOTH", "")
|
||||
}
|
||||
|
||||
// SelectAux is a convenience method to select AUX input
|
||||
// SelectAux is a convenience method to select AUX input.
|
||||
func (c *Client) SelectAux() error {
|
||||
return c.SelectSource("AUX", "")
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestClient_SelectSource(t *testing.T) {
|
||||
{
|
||||
name: "Valid AUX source",
|
||||
source: "AUX",
|
||||
sourceAccount: "",
|
||||
sourceAccount: "AUX",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
@@ -305,7 +305,7 @@ func TestClient_ConvenienceSourceMethods(t *testing.T) {
|
||||
method: "aux",
|
||||
sourceAccount: "",
|
||||
expectedSource: "AUX",
|
||||
expectedAccount: "",
|
||||
expectedAccount: "AUX",
|
||||
},
|
||||
{
|
||||
name: "SelectTuneIn",
|
||||
@@ -530,7 +530,7 @@ func getExpectedItemName(source string) string {
|
||||
case "BLUETOOTH":
|
||||
return "Bluetooth"
|
||||
case "AUX":
|
||||
return "AUX Input"
|
||||
return "AUX IN"
|
||||
case "TUNEIN":
|
||||
return "TuneIn"
|
||||
case "PANDORA":
|
||||
|
||||
@@ -17,7 +17,8 @@ import (
|
||||
|
||||
// CertificateManager handles CA and certificate generation.
|
||||
type CertificateManager struct {
|
||||
CertsDir string
|
||||
CertsDir string
|
||||
CommonName string // CN for generated server certs; defaults to "localhost" if empty
|
||||
}
|
||||
|
||||
// NewCertificateManager creates a new CertificateManager.
|
||||
@@ -137,7 +138,7 @@ func (cm *CertificateManager) GetServerTLSConfig(domains []string) (*tls.Config,
|
||||
|
||||
// GenerateCA generates a new CA certificate and key.
|
||||
func (cm *CertificateManager) GenerateCA() error {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -255,11 +256,16 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
|
||||
}
|
||||
}
|
||||
|
||||
cn := cm.CommonName
|
||||
if cn == "" {
|
||||
cn = "localhost"
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"AfterTouch"},
|
||||
CommonName: domains[0],
|
||||
CommonName: cn,
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestCertificateManager(t *testing.T) {
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
cm.CommonName = "test.local"
|
||||
|
||||
// Test CA generation
|
||||
if err := cm.EnsureCA(); err != nil {
|
||||
@@ -67,8 +68,8 @@ func TestCertificateManager(t *testing.T) {
|
||||
t.Fatalf("Failed to parse certificate: %v", err)
|
||||
}
|
||||
|
||||
if cert.Subject.CommonName != domains[0] {
|
||||
t.Errorf("Expected CommonName %s, got %s", domains[0], cert.Subject.CommonName)
|
||||
if cert.Subject.CommonName != cm.CommonName {
|
||||
t.Errorf("Expected CommonName %s, got %s", cm.CommonName, cert.Subject.CommonName)
|
||||
}
|
||||
|
||||
// Check DNS names
|
||||
|
||||
@@ -309,7 +309,7 @@ func TestMargeAccountFullExcludesEmptyAmazonSource(t *testing.T) {
|
||||
t.Fatalf("Failed to write first device DeviceInfo.xml: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(firstDir, "Sources.xml"), []byte(`<sources>
|
||||
<source id="10006" type="Audio" createdOn="2026-01-01T00:00:00.000+00:00" updatedOn="2026-01-01T00:00:00.000+00:00" displayName="Amazon Music" secret="" secretType="token" sourceproviderid="20">
|
||||
<source id="10006" type="AMAZON" createdOn="2026-01-01T00:00:00.000+00:00" updatedOn="2026-01-01T00:00:00.000+00:00" displayName="Amazon Music" secret="" secretType="token" sourceproviderid="20">
|
||||
<sourceKey type="AMAZON" account=""/>
|
||||
</source>
|
||||
</sources>`), 0644); err != nil {
|
||||
|
||||
@@ -578,7 +578,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
|
||||
}
|
||||
|
||||
for _, acc := range accounts {
|
||||
log.Printf("[Amazon Bridge] Registering Amazon user %s in Marge for account %s", acc.UserID, accountID)
|
||||
log.Printf("[Amazon Bridge] Registering Amazon user %s in Marge for account %s", acc.Email, accountID)
|
||||
|
||||
// Build the AmazonSecret credential envelope expected by the speaker firmware.
|
||||
credMap := map[string]interface{}{
|
||||
@@ -594,7 +594,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.AmazonProviderID), string(credJSON), constants.CredentialTypeToken, acc.DisplayName)
|
||||
_, err = marge.AddSource(s.ds, accountID, acc.Email, strconv.Itoa(constants.AmazonProviderID), string(credJSON), constants.CredentialTypeToken, acc.DisplayName)
|
||||
if err != nil {
|
||||
log.Printf("[Amazon Bridge] Failed to register source in Marge: %v", err)
|
||||
continue
|
||||
@@ -623,7 +623,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
|
||||
cfg.Host = d.IPAddress
|
||||
cfg.Timeout = 5 * time.Second
|
||||
c := client.NewClient(cfg)
|
||||
creds := models.NewAmazonOAuthCredentials(acc.UserID, string(credJSON), acc.DisplayName)
|
||||
creds := models.NewAmazonOAuthCredentials(acc.Email, string(credJSON), acc.DisplayName)
|
||||
|
||||
if err := c.SetMusicServiceOAuthAccount(creds); err != nil {
|
||||
log.Printf("[Amazon Bridge] Failed to notify speaker %s via OAuth: %v", d.Name, err)
|
||||
@@ -633,7 +633,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
|
||||
log.Printf("[Amazon Bridge] Sync notification failed for speaker %s: %v", d.Name, err)
|
||||
log.Printf("[Amazon Bridge] Falling back to legacy account creation for speaker %s", d.Name)
|
||||
|
||||
legacyCreds := models.NewAmazonMusicCredentials(acc.UserID, string(credJSON))
|
||||
legacyCreds := models.NewAmazonMusicCredentials(acc.Email, string(credJSON))
|
||||
if err := c.SetMusicServiceAccount(legacyCreds); err != nil {
|
||||
log.Printf("[Amazon Bridge] Legacy fallback failed for speaker %s: %v", d.Name, err)
|
||||
} else {
|
||||
|
||||
@@ -256,11 +256,15 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
s.serverURL = settings.ServerURL
|
||||
|
||||
s.discoveryEnabled = settings.DiscoveryEnabled
|
||||
if settings.DiscoveryInterval != "" {
|
||||
s.discoveryInterval = interval
|
||||
}
|
||||
|
||||
s.discoveryEnabled = settings.DiscoveryEnabled
|
||||
if s.discoveryInterval == 0 {
|
||||
s.discoveryEnabled = false
|
||||
}
|
||||
|
||||
s.dnsEnabled = settings.DNSEnabled
|
||||
|
||||
// Handle comma-separated upstream DNS servers
|
||||
|
||||
@@ -333,6 +333,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>Root CA Certificate:</strong>
|
||||
<div style="margin-top: 8px; font-size: 0.9em; color: #555; max-width: 600px">
|
||||
Import this certificate into your OS or browser trust store to
|
||||
trust HTTPS connections to this AfterTouch server from other
|
||||
clients (e.g. curl, Python scripts, browsers).
|
||||
</div>
|
||||
<div style="margin-top: 8px">
|
||||
<a href="/setup/ca.crt" download="soundtouch-ca.crt">
|
||||
<button type="button">Download CA Certificate</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<span
|
||||
@@ -531,6 +545,12 @@
|
||||
>
|
||||
Trust CA Now
|
||||
</button>
|
||||
<a
|
||||
href="/setup/ca.crt"
|
||||
download="soundtouch-ca.crt"
|
||||
style="margin-left: 10px; font-size: 0.85em"
|
||||
title="Download CA cert to import into other clients"
|
||||
>Download CA cert</a>
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -854,6 +874,19 @@
|
||||
>Planned Config (AfterTouch)</span
|
||||
>
|
||||
<pre id="planned-config"></pre>
|
||||
<div
|
||||
id="resolve-ip-error"
|
||||
style="display: none; margin-top: 10px; padding: 10px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; color: #856404;"
|
||||
>
|
||||
⚠️ <strong>Hostname resolution warning:</strong>
|
||||
<span id="resolve-ip-error-msg"></span>
|
||||
<br/>
|
||||
The planned IP shown above may be incorrect.
|
||||
Migration methods that write IPs to the device
|
||||
(hosts, resolv.conf) will refuse to proceed until
|
||||
the hostname can be resolved from the device itself.
|
||||
<a href="https://github.com/gesellix/bose-soundtouch/blob/main/docs/guides/TROUBLESHOOTING.md#hostname-resolution" target="_blank" style="color: #856404;">Learn more →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="planned-hosts-pane"
|
||||
|
||||
@@ -1816,6 +1816,14 @@ async function showSummary(deviceId) {
|
||||
document.getElementById("planned-hosts").innerText = summary.planned_hosts || "";
|
||||
document.getElementById("planned-resolv").innerText = summary.planned_resolv || "";
|
||||
|
||||
const resolveErrEl = document.getElementById("resolve-ip-error");
|
||||
if (summary.resolve_ip_error) {
|
||||
document.getElementById("resolve-ip-error-msg").innerText = summary.resolve_ip_error;
|
||||
resolveErrEl.style.display = "block";
|
||||
} else {
|
||||
resolveErrEl.style.display = "none";
|
||||
}
|
||||
|
||||
const currentResolvElem = document.getElementById("current-resolv-content");
|
||||
if (currentResolvElem) {
|
||||
currentResolvElem.innerText = summary.current_resolv_conf || "Not available";
|
||||
|
||||
@@ -106,7 +106,11 @@ func ensureTimestamps(s *models.ConfiguredSource) {
|
||||
|
||||
func ensureSourceType(s *models.ConfiguredSource) {
|
||||
if s.Type == "" || (s.SourceKey.Type != "" && s.SourceKey.Type != constants.ProviderAux && s.SourceKey.Type != constants.ProviderBluetooth) {
|
||||
s.Type = "Audio"
|
||||
if s.SourceKey.Type == constants.ProviderAmazon {
|
||||
s.Type = constants.ProviderAmazon
|
||||
} else {
|
||||
s.Type = "Audio"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+107
-65
@@ -72,6 +72,7 @@ type MigrationSummary struct {
|
||||
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
|
||||
PlannedResolv string `json:"planned_resolv,omitempty"`
|
||||
IsMigrated bool `json:"is_migrated"`
|
||||
ResolveIPError string `json:"resolve_ip_error,omitempty"`
|
||||
MirrorEnabled bool `json:"mirror_enabled"`
|
||||
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
||||
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
|
||||
@@ -257,40 +258,8 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
|
||||
summary.PlannedConfig = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + string(xmlContent)
|
||||
|
||||
// 2b. Initial planned hosts config
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err == nil {
|
||||
hostName := parsedURL.Hostname()
|
||||
if hostName != "" && hostName != "localhost" {
|
||||
client := m.NewSSH(deviceIP)
|
||||
hostIP := m.resolveIP(hostName, client)
|
||||
|
||||
// Predicted aftertouch.resolv.conf
|
||||
summary.PlannedResolv = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
|
||||
|
||||
domains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
"content.api.bose.io",
|
||||
"events.api.bosecm.com",
|
||||
"bose-prod.apigee.net",
|
||||
"worldwide.bose.com",
|
||||
"music.api.bose.com",
|
||||
"media.bose.io",
|
||||
"downloads.bose.com",
|
||||
"voice.api.bose.io",
|
||||
}
|
||||
|
||||
var hostsLines []string
|
||||
for _, domain := range domains {
|
||||
hostsLines = append(hostsLines, fmt.Sprintf("%s\t%s", hostIP, domain))
|
||||
}
|
||||
|
||||
summary.PlannedHosts = strings.Join(hostsLines, "\n")
|
||||
}
|
||||
}
|
||||
// 2b. Planned network config (hosts entries, resolv.conf preview, resolve error)
|
||||
m.populatePlannedNetworkConfig(summary, deviceIP, targetURL)
|
||||
|
||||
// 3. Check for remote services files
|
||||
m.checkRemoteServices(summary, deviceIP)
|
||||
@@ -307,18 +276,7 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
}
|
||||
|
||||
// 5. Provide HTTPS URL for testing
|
||||
if parsedURL, err := url.Parse(targetURL); err == nil {
|
||||
hostIP := parsedURL.Hostname()
|
||||
if hostIP != "" {
|
||||
// Find HTTPS port from environment or default
|
||||
httpsPort := os.Getenv("HTTPS_PORT")
|
||||
if httpsPort == "" {
|
||||
httpsPort = "8443"
|
||||
}
|
||||
|
||||
summary.ServerHTTPSURL = fmt.Sprintf("https://%s:%s/health", hostIP, httpsPort)
|
||||
}
|
||||
}
|
||||
summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL)
|
||||
|
||||
// 6. Check if migrated
|
||||
m.checkIsMigrated(summary, deviceIP)
|
||||
@@ -337,6 +295,67 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, deviceIP, targetURL string) {
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hostName := parsedURL.Hostname()
|
||||
if hostName == "" || hostName == "localhost" {
|
||||
return
|
||||
}
|
||||
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
hostIP, resolveErr := m.resolveIP(hostName, client)
|
||||
if resolveErr != nil {
|
||||
summary.ResolveIPError = resolveErr.Error()
|
||||
}
|
||||
|
||||
if hostIP == "" {
|
||||
hostIP = hostName
|
||||
}
|
||||
|
||||
summary.PlannedResolv = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
|
||||
|
||||
domains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
"content.api.bose.io",
|
||||
"events.api.bosecm.com",
|
||||
"bose-prod.apigee.net",
|
||||
"worldwide.bose.com",
|
||||
"music.api.bose.com",
|
||||
"media.bose.io",
|
||||
"downloads.bose.com",
|
||||
"voice.api.bose.io",
|
||||
}
|
||||
|
||||
hostsLines := make([]string, len(domains))
|
||||
for i, domain := range domains {
|
||||
hostsLines[i] = fmt.Sprintf("%s\t%s", hostIP, domain)
|
||||
}
|
||||
|
||||
summary.PlannedHosts = strings.Join(hostsLines, "\n")
|
||||
}
|
||||
|
||||
func (m *Manager) buildServerHTTPSURL(targetURL string) string {
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil || parsedURL.Hostname() == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
httpsPort := os.Getenv("HTTPS_PORT")
|
||||
if httpsPort == "" {
|
||||
httpsPort = "8443"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://%s:%s/health", parsedURL.Hostname(), httpsPort)
|
||||
}
|
||||
|
||||
// checkIsMigrated determines if the device is already migrated to AfterTouch.
|
||||
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
|
||||
if !summary.SSHSuccess {
|
||||
@@ -418,7 +437,7 @@ func (m *Manager) isResolvConfMigrated(client SSHClient, summary *MigrationSumma
|
||||
return true
|
||||
}
|
||||
|
||||
resolvedIP := m.resolveIP(targetHost, client)
|
||||
resolvedIP, _ := m.resolveIP(targetHost, client)
|
||||
if resolvedIP != "" && strings.Contains(summary.CurrentResolvConf, resolvedIP) && summary.CACertTrusted {
|
||||
return true
|
||||
}
|
||||
@@ -765,11 +784,11 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
|
||||
if backupOut, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil {
|
||||
logs += fmt.Sprintf("Backing up original config to %s.original (check: %s)\n", remotePath, backupOut)
|
||||
fmt.Printf("Backing up original config to %s.original\n", remotePath)
|
||||
// Try to copy existing config to .original, ensuring filesystem is writable
|
||||
|
||||
if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err != nil {
|
||||
logs += fmt.Sprintf("cp backup failed: %v (output: %s)\n", err, output)
|
||||
fmt.Printf("cp backup failed: %v (output: %s)\n", err, output)
|
||||
// Fallback to manual upload if cp failed (might not have cp?)
|
||||
|
||||
if config, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil && config != "" {
|
||||
if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil {
|
||||
logs += "failed to upload backup config: " + err.Error() + "\n"
|
||||
@@ -787,10 +806,7 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
|
||||
logs += "Backup .original already exists\n"
|
||||
}
|
||||
|
||||
// 1. Upload the configuration (rw is handled by calling it before if needed, but UploadContent uses cat > which needs rw)
|
||||
// We'll wrap the upload in a way that EnsureRemoteServices and others might benefit,
|
||||
// but UploadContent is a separate method. We should probably add rw to UploadContent or call it before.
|
||||
// Actually, let's call rw before UploadContent here.
|
||||
// 1. Upload the configuration
|
||||
out, _ = client.Run(rwCmd)
|
||||
|
||||
logs += rwCmd + ": " + out + "\n"
|
||||
@@ -1062,7 +1078,11 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
|
||||
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
|
||||
}
|
||||
|
||||
hostIP := m.resolveIP(hostName, client)
|
||||
hostIP, err := m.resolveIP(hostName, client)
|
||||
if err != nil {
|
||||
return logs, fmt.Errorf("cannot resolve target hostname for migration: %w", err)
|
||||
}
|
||||
|
||||
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
|
||||
|
||||
// 2. Prepare /etc/hosts entries
|
||||
@@ -1218,7 +1238,11 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro
|
||||
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
|
||||
}
|
||||
|
||||
hostIP := m.resolveIP(hostName, client)
|
||||
hostIP, err := m.resolveIP(hostName, client)
|
||||
if err != nil {
|
||||
return logs, fmt.Errorf("cannot resolve target hostname for migration: %w", err)
|
||||
}
|
||||
|
||||
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
|
||||
|
||||
// 2. Prepare /mnt/nv/soundtouch-service/aftertouch.resolv.conf content
|
||||
@@ -1888,7 +1912,12 @@ func (m *Manager) parseTargetURLAndResolveIP(targetURL string, client SSHClient)
|
||||
return "", nil, fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
|
||||
}
|
||||
|
||||
return m.resolveIP(hostName, client), parsedURL, nil
|
||||
hostIP, err := m.resolveIP(hostName, client)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("cannot resolve target hostname: %w", err)
|
||||
}
|
||||
|
||||
return hostIP, parsedURL, nil
|
||||
}
|
||||
|
||||
func (m *Manager) addTemporaryHostEntry(client SSHClient, deviceIP, testDomain, testEntry, rwCmd string) error {
|
||||
@@ -2035,15 +2064,21 @@ func (m *Manager) TestConnection(deviceIP, targetURL string, useExplicitCA bool)
|
||||
|
||||
// GetResolvedIP returns the resolved IP for a hostname, attempting to resolve it from any connected device first.
|
||||
func (m *Manager) GetResolvedIP(host string) string {
|
||||
return m.resolveIP(host, nil)
|
||||
ip, _ := m.resolveIP(host, nil)
|
||||
return ip
|
||||
}
|
||||
|
||||
func (m *Manager) resolveIP(host string, client SSHClient) string {
|
||||
// resolveIP resolves a hostname to an IP address.
|
||||
// It first tries to resolve from the device via SSH ping (authoritative for migration).
|
||||
// If that fails, it falls back to resolving from the service itself.
|
||||
// An error is returned whenever the SSH ping did not produce the IP, so callers that
|
||||
// write config to the device can abort rather than risk writing an unresolvable hostname.
|
||||
func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
|
||||
if net.ParseIP(host) != nil {
|
||||
return host
|
||||
return host, nil
|
||||
}
|
||||
|
||||
// 1. Try resolving FROM the device via SSH (best for containers/NAT)
|
||||
// 1. Try resolving FROM the device via SSH (authoritative: gives the IP the device will actually use)
|
||||
if client != nil {
|
||||
// Use ping to resolve hostname on the device.
|
||||
// Busybox ping output usually looks like: PING host (1.2.3.4): 56 data bytes
|
||||
@@ -2057,26 +2092,33 @@ func (m *Manager) resolveIP(host string, client SSHClient) string {
|
||||
ip := output[start+1 : end]
|
||||
if net.ParseIP(ip) != nil {
|
||||
fmt.Printf("Resolved %s to %s from device\n", host, ip)
|
||||
return ip
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: resolve FROM the service itself
|
||||
// 2. Fallback: resolve FROM the service itself (unreliable for migration — NAT/split-DNS may differ)
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return host // Fallback to host if resolution fails
|
||||
return "", fmt.Errorf("cannot resolve %q: SSH ping from device failed and service-side DNS lookup also failed", host)
|
||||
}
|
||||
|
||||
// Prefer IPv4
|
||||
var resolved string
|
||||
|
||||
for _, ip := range ips {
|
||||
if ip.To4() != nil {
|
||||
return ip.String()
|
||||
resolved = ip.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return ips[0].String()
|
||||
if resolved == "" {
|
||||
resolved = ips[0].String()
|
||||
}
|
||||
|
||||
return resolved, fmt.Errorf("resolved %q to %s from service, not from device — result may be wrong if NAT or split-DNS is in use", host, resolved)
|
||||
}
|
||||
|
||||
// SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore.
|
||||
|
||||
@@ -597,17 +597,22 @@ func TestTestHostsRedirection(t *testing.T) {
|
||||
func TestResolveIP(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
// Test with IP
|
||||
if m.resolveIP("1.2.3.4", nil) != "1.2.3.4" {
|
||||
t.Errorf("Expected 1.2.3.4, got %s", m.resolveIP("1.2.3.4", nil))
|
||||
// IP passthrough: no resolution needed, no error
|
||||
ip, err := m.resolveIP("1.2.3.4", nil)
|
||||
if ip != "1.2.3.4" || err != nil {
|
||||
t.Errorf("Expected 1.2.3.4/nil, got %s/%v", ip, err)
|
||||
}
|
||||
|
||||
// Test with localhost
|
||||
if m.resolveIP("localhost", nil) != "127.0.0.1" && m.resolveIP("localhost", nil) != "::1" {
|
||||
t.Errorf("Expected localhost resolution, got %s", m.resolveIP("localhost", nil))
|
||||
// localhost resolves from service DNS; error expected (no SSH client)
|
||||
ip, err = m.resolveIP("localhost", nil)
|
||||
if ip != "127.0.0.1" && ip != "::1" {
|
||||
t.Errorf("Expected localhost resolution, got %s", ip)
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for service-side fallback, got nil")
|
||||
}
|
||||
|
||||
// Test with device resolution (mocked)
|
||||
// Device SSH ping succeeds: IP returned, no error
|
||||
mock := &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.Contains(command, "ping -c 1 myhost") {
|
||||
@@ -616,13 +621,18 @@ func TestResolveIP(t *testing.T) {
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
if m.resolveIP("myhost", mock) != "10.0.0.5" {
|
||||
t.Errorf("Expected 10.0.0.5 from device, got %s", m.resolveIP("myhost", mock))
|
||||
ip, err = m.resolveIP("myhost", mock)
|
||||
if ip != "10.0.0.5" || err != nil {
|
||||
t.Errorf("Expected 10.0.0.5/nil from device, got %s/%v", ip, err)
|
||||
}
|
||||
|
||||
// Test with non-existent host (should fallback to input)
|
||||
if m.resolveIP("non-existent.host.fake", nil) != "non-existent.host.fake" {
|
||||
t.Errorf("Expected fallback to input, got %s", m.resolveIP("non-existent.host.fake", nil))
|
||||
// Non-existent host, no SSH client: both methods fail, error returned
|
||||
ip, err = m.resolveIP("non-existent.host.fake", nil)
|
||||
if ip != "" {
|
||||
t.Errorf("Expected empty IP on failure, got %s", ip)
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for unresolvable host, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1525,6 +1535,40 @@ func TestCheckIsMigrated(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestCheckCurrentConfig_ReadsOriginalPath verifies that checkCurrentConfig reads
|
||||
// from SoundTouchSdkPrivateCfgPath on an unmigrated device (issue #214 regression test).
|
||||
func TestCheckCurrentConfig_ReadsOriginalPath(t *testing.T) {
|
||||
m := NewManager("http://aftertouch:8000", nil, nil)
|
||||
|
||||
originalCfg := "<SoundTouchSdkPrivateCfg><margeServerUrl>http://streaming.bose.com</margeServerUrl></SoundTouchSdkPrivateCfg>"
|
||||
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.HasPrefix(command, "[ -f ") && strings.Contains(command, ".original") {
|
||||
return "", fmt.Errorf("exit status 1")
|
||||
}
|
||||
if command == fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath) {
|
||||
return originalCfg, nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
cfg, err := m.checkCurrentConfig(summary, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("checkCurrentConfig returned unexpected error: %v", err)
|
||||
}
|
||||
if cfg != originalCfg {
|
||||
t.Errorf("Expected current config to be the original SoundTouchSdkPrivateCfg.xml, got %q", cfg)
|
||||
}
|
||||
if !summary.SSHSuccess {
|
||||
t.Errorf("Expected SSHSuccess to be true when original config is readable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSpeaker_ResolvBlocking(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "setup-test")
|
||||
if err != nil {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -1,5 +1,4 @@
|
||||
// Package handlers contains tests for HTTP handlers.
|
||||
package handlers
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
/* ── Reset & Base ─────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--surface: #ffffff;
|
||||
--border: #e0e0e0;
|
||||
--text: #1a1a1a;
|
||||
--text-dim: #666;
|
||||
--accent: #000000;
|
||||
--accent-fg: #ffffff;
|
||||
--online: #22c55e;
|
||||
--offline: #9ca3af;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.10), 0 1px 2px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111;
|
||||
--surface: #1e1e1e;
|
||||
--border: #333;
|
||||
--text: #f0f0f0;
|
||||
--text-dim: #aaa;
|
||||
--accent: #e0e0e0;
|
||||
--accent-fg:#111;
|
||||
}
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; cursor: pointer; }
|
||||
button { cursor: pointer; font: inherit; border: none; background: none; }
|
||||
ul { list-style: none; }
|
||||
img { display: block; max-width: 100%; }
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────────── */
|
||||
.app { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
|
||||
/* ── Navbar ──────────────────────────────────────────────────────────────── */
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.25rem;
|
||||
height: 52px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.brand { font-size: 1.1rem; font-weight: 600; letter-spacing: .02em; }
|
||||
|
||||
.nav-links { display: flex; align-items: center; gap: .75rem; }
|
||||
|
||||
.nav-links a, .nav-links .btn-icon {
|
||||
color: var(--accent-fg);
|
||||
opacity: .75;
|
||||
font-size: .9rem;
|
||||
padding: .25rem .5rem;
|
||||
border-radius: 4px;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
.nav-links a:hover, .nav-links .btn-icon:hover, .nav-links a.active { opacity: 1; }
|
||||
|
||||
.nav-tunein-icon { height: 18px; display: inline-block; filter: brightness(0) invert(1); opacity: .75; }
|
||||
.nav-links a:hover .nav-tunein-icon, .nav-links a.active .nav-tunein-icon { opacity: 1; }
|
||||
|
||||
/* ── Main content ─────────────────────────────────────────────────────────── */
|
||||
.main-content { flex: 1; padding: 1.5rem 1.25rem; max-width: 960px; width: 100%; margin: 0 auto; }
|
||||
|
||||
/* ── Page header ──────────────────────────────────────────────────────────── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.page-header h2 { font-size: 1.4rem; font-weight: 600; flex: 1; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
padding: .45rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: .875rem;
|
||||
font-weight: 500;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: .85; }
|
||||
|
||||
.btn-secondary {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: .45rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: .875rem;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg); }
|
||||
|
||||
.btn-icon {
|
||||
color: inherit;
|
||||
font-size: 1.1rem;
|
||||
padding: .25rem .4rem;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.btn-icon:hover { opacity: .7; }
|
||||
|
||||
.back-btn {
|
||||
color: var(--text-dim);
|
||||
font-size: .875rem;
|
||||
padding: .3rem .6rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.back-btn:hover { background: var(--bg); }
|
||||
|
||||
/* ── Device grid ─────────────────────────────────────────────────────────── */
|
||||
.device-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow .15s, transform .1s;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.device-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.12); transform: translateY(-1px); }
|
||||
|
||||
.device-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: .25rem; }
|
||||
.device-name { font-weight: 600; font-size: .95rem; }
|
||||
.device-type { font-size: .8rem; color: var(--text-dim); margin-bottom: .5rem; }
|
||||
|
||||
.device-indicator {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.device-indicator.online { background: var(--online); }
|
||||
.device-indicator.offline { background: var(--offline); }
|
||||
|
||||
.now-playing-mini { font-size: .8rem; color: var(--text-dim); margin-top: .25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.play-status { margin-right: .3rem; }
|
||||
.standby-label { font-size: .8rem; color: var(--text-dim); margin-top: .25rem; }
|
||||
|
||||
/* ── Device detail ───────────────────────────────────────────────────────── */
|
||||
.device-detail { max-width: 560px; }
|
||||
|
||||
/* ── Now playing ─────────────────────────────────────────────────────────── */
|
||||
.now-playing {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
box-shadow: var(--shadow);
|
||||
min-height: 80px;
|
||||
align-items: center;
|
||||
}
|
||||
.now-playing.standby { color: var(--text-dim); font-size: .9rem; }
|
||||
|
||||
.album-art { width: 64px; height: 64px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
||||
|
||||
.track-info { flex: 1; overflow: hidden; }
|
||||
.track-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.track-artist { font-size: .875rem; color: var(--text-dim); margin-top: .15rem; }
|
||||
.track-album { font-size: .8rem; color: var(--text-dim); }
|
||||
.track-meta { display: flex; align-items: center; gap: .5rem; margin-top: .25rem; }
|
||||
.track-source { font-size: .75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.buffering-badge { font-size: .7rem; color: var(--text-dim); background: var(--bg); border-radius: 4px; padding: .1rem .35rem; }
|
||||
|
||||
/* ── Transport controls ──────────────────────────────────────────────────── */
|
||||
.controls {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.transport { display: flex; align-items: center; gap: .5rem; margin-bottom: .75rem; }
|
||||
|
||||
.ctrl-btn {
|
||||
font-size: 1.25rem;
|
||||
padding: .4rem .7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
transition: background .12s;
|
||||
}
|
||||
.ctrl-btn:hover { background: var(--bg); }
|
||||
.ctrl-btn.play-btn { font-size: 1.5rem; padding: .4rem .9rem; }
|
||||
.ctrl-btn.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
|
||||
.volume-row { display: flex; align-items: center; gap: .75rem; }
|
||||
.volume-icon { font-size: 1rem; }
|
||||
.volume-slider { flex: 1; accent-color: var(--accent); }
|
||||
.volume-value { font-size: .8rem; color: var(--text-dim); min-width: 2.5ch; text-align: right; }
|
||||
|
||||
.bass-row { display: flex; align-items: center; gap: .75rem; margin-top: .5rem; }
|
||||
.bass-label { font-size: .8rem; color: var(--text-dim); width: 2.5ch; }
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────────────────── */
|
||||
.progress-row { margin-top: .35rem; display: flex; align-items: center; gap: .5rem; }
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width .9s linear;
|
||||
}
|
||||
.progress-time { font-size: .7rem; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
/* ── Presets ─────────────────────────────────────────────────────────────── */
|
||||
.presets-section, .sources-section { margin-top: 1.25rem; }
|
||||
.section-title { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); margin-bottom: .6rem; }
|
||||
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: .4rem;
|
||||
}
|
||||
|
||||
.preset-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
padding: .4rem .2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background .1s, box-shadow .1s;
|
||||
min-height: 72px;
|
||||
}
|
||||
.preset-slot:hover:not(:disabled) { background: var(--bg); box-shadow: var(--shadow); }
|
||||
.preset-slot:disabled { opacity: .4; cursor: default; }
|
||||
.preset-slot.active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
.preset-slot.active .preset-name { color: var(--accent-fg); }
|
||||
|
||||
.preset-art { width: 36px; height: 36px; border-radius: 4px; object-fit: cover; }
|
||||
.preset-source-label { font-size: .6rem; font-weight: 600; text-transform: uppercase; opacity: .6; }
|
||||
.preset-name { font-size: .65rem; text-align: center; line-height: 1.2; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; word-break: break-word; color: var(--text-dim); }
|
||||
.preset-num {
|
||||
position: absolute; top: 2px; right: 4px;
|
||||
font-size: .6rem; font-weight: 700; color: var(--text-dim); opacity: .5;
|
||||
}
|
||||
|
||||
/* ── Sources ─────────────────────────────────────────────────────────────── */
|
||||
.source-list { display: flex; flex-wrap: wrap; gap: .4rem; }
|
||||
|
||||
.source-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
padding: .35rem .7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
font-size: .8rem;
|
||||
transition: background .1s, border-color .1s;
|
||||
}
|
||||
.source-btn:hover { background: var(--bg); }
|
||||
.source-btn.active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
.source-btn.local { border-style: dashed; }
|
||||
.source-icon { font-size: .9rem; line-height: 1; }
|
||||
.source-name { font-weight: 500; }
|
||||
|
||||
/* ── Zone ────────────────────────────────────────────────────────────────── */
|
||||
.zone-section { margin-top: 1.25rem; }
|
||||
|
||||
.zone-row { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
||||
.zone-status-label { font-size: .875rem; color: var(--text-dim); }
|
||||
|
||||
.zone-members { display: flex; flex-direction: column; gap: .3rem; }
|
||||
.zone-member {
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
padding: .4rem .6rem;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.zone-master-row { background: var(--bg); }
|
||||
|
||||
.zone-badge {
|
||||
font-size: .65rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .05em; padding: .15rem .4rem; border-radius: 3px; flex-shrink: 0;
|
||||
}
|
||||
.zone-badge.master { background: var(--accent); color: var(--accent-fg); }
|
||||
.zone-badge.slave { background: var(--border); color: var(--text-dim); }
|
||||
|
||||
.zone-member-name { flex: 1; font-size: .875rem; }
|
||||
.zone-remove { font-size: .75rem; color: var(--text-dim); padding: .15rem .35rem; }
|
||||
.zone-remove:hover { color: var(--text); }
|
||||
|
||||
.zone-actions { display: flex; gap: .5rem; margin-top: .25rem; flex-wrap: wrap; }
|
||||
.zone-btn { font-size: .8rem; padding: .3rem .7rem; }
|
||||
|
||||
/* ── Recents ─────────────────────────────────────────────────────────────── */
|
||||
.recents-section { margin-top: 1.25rem; }
|
||||
|
||||
.recents-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
width: 100%;
|
||||
padding: .5rem .6rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
transition: background .1s;
|
||||
}
|
||||
.recent-item:hover { background: var(--bg); }
|
||||
|
||||
.recent-art {
|
||||
width: 40px; height: 40px; border-radius: 4px;
|
||||
object-fit: cover; flex-shrink: 0;
|
||||
}
|
||||
.recent-art-empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg); font-size: 1.1rem;
|
||||
}
|
||||
.recent-info { flex: 1; overflow: hidden; }
|
||||
.recent-name { display: block; font-size: .875rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.recent-source { display: block; font-size: .75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; margin-top: .1rem; }
|
||||
.recent-play { color: var(--text-dim); font-size: .75rem; flex-shrink: 0; opacity: .5; }
|
||||
.recent-item:hover .recent-play { opacity: 1; }
|
||||
|
||||
/* ── TuneIn ──────────────────────────────────────────────────────────────── */
|
||||
.tunein-toolbar { display: flex; gap: .5rem; margin-bottom: 1rem; }
|
||||
.tunein-search-input {
|
||||
flex: 1;
|
||||
padding: .45rem .75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: .875rem;
|
||||
}
|
||||
.tunein-search-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
.breadcrumb { display: flex; align-items: center; gap: .4rem; margin-bottom: .75rem; font-size: .85rem; flex-wrap: wrap; }
|
||||
.breadcrumb-sep { color: var(--text-dim); }
|
||||
.breadcrumb-link { color: var(--text-dim); cursor: pointer; }
|
||||
.breadcrumb-link:hover { text-decoration: underline; }
|
||||
.breadcrumb-current { font-weight: 500; }
|
||||
|
||||
.loading-bar {
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent) 0%, transparent 100%);
|
||||
border-radius: 1px;
|
||||
margin-bottom: 1rem;
|
||||
animation: loading 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes loading { 0%,100% { opacity: .4; } 50% { opacity: 1; } }
|
||||
|
||||
.tunein-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.tunein-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
padding: .6rem .75rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background .1s;
|
||||
}
|
||||
.tunein-item:hover { background: var(--bg); }
|
||||
|
||||
.tunein-thumb { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
||||
.tunein-item-info { flex: 1; overflow: hidden; }
|
||||
.tunein-item-name { display: block; font-size: .9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tunein-item-desc { display: block; font-size: .75rem; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tunein-item-arrow { color: var(--text-dim); font-size: .9rem; flex-shrink: 0; }
|
||||
|
||||
/* ── Device picker overlay ───────────────────────────────────────────────── */
|
||||
.overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.device-picker {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.2);
|
||||
}
|
||||
.picker-title { font-weight: 600; margin-bottom: .25rem; }
|
||||
.picker-item-name { font-size: .875rem; color: var(--text-dim); margin-bottom: 1rem; }
|
||||
.picker-devices { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1rem; }
|
||||
.picker-device-btn {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: .6rem 1rem;
|
||||
text-align: left;
|
||||
font-size: .9rem;
|
||||
transition: background .1s;
|
||||
}
|
||||
.picker-device-btn:hover { background: var(--border); }
|
||||
.picker-cancel { width: 100%; }
|
||||
.picker-no-devices { font-size: .875rem; color: var(--text-dim); text-align: center; padding: .5rem 0; }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
.empty-state { text-align: center; padding: 4rem 1rem; color: var(--text-dim); }
|
||||
.empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: .4; }
|
||||
.empty-state p { margin-bottom: 1.5rem; }
|
||||
|
||||
/* ── Toast ───────────────────────────────────────────────────────────────── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
padding: .6rem 1.25rem;
|
||||
border-radius: 999px;
|
||||
font-size: .875rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.2);
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
animation: fade-in .2s ease;
|
||||
}
|
||||
@keyframes fade-in { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 859 B |
@@ -0,0 +1,9 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Morse 'S' (drei Punkte) -->
|
||||
<circle cx="8" cy="10" r="3" fill="#0055aa"/>
|
||||
<circle cx="16" cy="10" r="3" fill="#0055aa"/>
|
||||
<circle cx="24" cy="10" r="3" fill="#0055aa"/>
|
||||
|
||||
<!-- Morse 'T' (ein langer Strich) -->
|
||||
<rect x="5" y="18" width="22" height="6" rx="2" fill="#ffcc00"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 381 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoundTouch Web</title>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"preact": "/static/vendor/preact.module.js",
|
||||
"preact/hooks": "/static/vendor/preact-hooks.module.js",
|
||||
"htm": "/static/vendor/htm.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg" />
|
||||
<link rel="alternate icon" href="/static/img/favicon.ico" />
|
||||
<link rel="stylesheet" href="/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
async function req(url, opts = {}) {
|
||||
const r = await fetch(url, opts);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
devices: () => req('/api/devices'),
|
||||
device: (id) => req(`/api/device/${id}`),
|
||||
discover: () => req('/api/discover', { method: 'POST' }),
|
||||
key: (id, key) => req(`/api/device-key/${id}/${key}`, { method: 'POST' }),
|
||||
volume: (id, level) => req(`/api/device-volume/${id}/${level}`, { method: 'POST' }),
|
||||
bass: (id, level) => req(`/api/control/${id}/bass`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ level }),
|
||||
}),
|
||||
power: (id) => req(`/api/device-power/${id}`, { method: 'POST' }),
|
||||
recents: (id) => req(`/api/device-recents/${id}`),
|
||||
zone: (id) => req(`/api/zone/${id}`),
|
||||
zoneAdd: (masterId, slaveId) => req(`/api/zone/${masterId}/add/${slaveId}`, { method: 'POST' }),
|
||||
zoneRemove: (masterId, slaveId) => req(`/api/zone/${masterId}/remove/${slaveId}`, { method: 'POST' }),
|
||||
zoneDissolve: (id) => req(`/api/zone/${id}/dissolve`, { method: 'POST' }),
|
||||
zoneLeave: (id) => req(`/api/zone/${id}/leave`, { method: 'POST' }),
|
||||
play: (id, item) => req(`/api/device-play/${id}`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(item),
|
||||
}),
|
||||
tuneInBrowse: (path) => req(path ? `/api/tunein/navigate/${path}` : '/api/tunein/navigate'),
|
||||
tuneInSearch: (q) => req(`/api/tunein/search?q=${encodeURIComponent(q)}`),
|
||||
control: (id, action, presetId) => req(`/api/control/${id}/${action}?id=${presetId}`),
|
||||
selectSource: (id, source, account) => req(`/api/control/${id}/source?name=${encodeURIComponent(source)}&account=${encodeURIComponent(account || '')}`),
|
||||
tuneInPlay: (deviceId, item) => req(`/api/tunein/play/${deviceId}`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(item),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { h, render } from 'preact';
|
||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { DeviceList } from './components/DeviceList.js';
|
||||
import { NowPlaying } from './components/NowPlaying.js';
|
||||
import { Controls } from './components/Controls.js';
|
||||
import { Presets } from './components/Presets.js';
|
||||
import { Sources } from './components/Sources.js';
|
||||
import { Zone } from './components/Zone.js';
|
||||
import { Recents } from './components/Recents.js';
|
||||
import { TuneInBrowser } from './components/TuneInBrowser.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function DeviceDetail({ deviceId, devices, onBack }) {
|
||||
const device = devices[deviceId];
|
||||
|
||||
if (!device) {
|
||||
return html`
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onClick=${onBack}>← Back</button>
|
||||
</div>
|
||||
<p>Device not found.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="device-detail">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onClick=${onBack}>← Back</button>
|
||||
<h2>${device.info?.Name || deviceId}</h2>
|
||||
<button class="btn-icon" onClick=${() => api.power(deviceId)} title="Power">⏻</button>
|
||||
</div>
|
||||
<${NowPlaying} nowPlaying=${device.status?.nowPlaying} />
|
||||
<${Controls} deviceId=${deviceId} status=${device.status} />
|
||||
<${Presets} deviceId=${deviceId} status=${device.status} />
|
||||
<${Sources} deviceId=${deviceId} status=${device.status} />
|
||||
<${Zone} deviceId=${deviceId} devices=${devices} />
|
||||
<${Recents} deviceId=${deviceId} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [devices, setDevices] = useState({});
|
||||
const [page, setPage] = useState('devices');
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
||||
let reconnectTimer;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'devices') {
|
||||
setDevices(msg.data || {});
|
||||
} else if (msg.type === 'discovery_status') {
|
||||
if (msg.data?.status === 'completed') {
|
||||
showToast(`Found ${msg.data.deviceCount} device(s)`);
|
||||
}
|
||||
} else if (msg.type === 'status_update' && msg.deviceId) {
|
||||
setDevices(prev => ({
|
||||
...prev,
|
||||
[msg.deviceId]: { ...prev[msg.deviceId], status: msg.data },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
reconnectTimer = setTimeout(() => location.reload(), 5000);
|
||||
};
|
||||
|
||||
return () => {
|
||||
clearTimeout(reconnectTimer);
|
||||
ws.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function showToast(msg) {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}
|
||||
|
||||
const navigate = useCallback((p, id = null) => {
|
||||
setPage(p);
|
||||
setSelectedId(id);
|
||||
}, []);
|
||||
|
||||
async function discover() {
|
||||
showToast('Discovering devices…');
|
||||
await api.discover();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="app">
|
||||
<nav class="navbar">
|
||||
<a class="brand" href="#" onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
|
||||
SoundTouch
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="#" class="${page === 'devices' || page === 'device' ? 'active' : ''}"
|
||||
onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
|
||||
Devices
|
||||
</a>
|
||||
<a href="#" class="${page === 'tunein' ? 'active' : ''}"
|
||||
onClick=${(e) => { e.preventDefault(); navigate('tunein'); }}>
|
||||
<img src="/static/img/tunein-mono.svg" alt="TuneIn" class="nav-tunein-icon" />
|
||||
</a>
|
||||
<button class="btn-icon" onClick=${discover} title="Discover">⟳</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
${page === 'devices' && html`
|
||||
<${DeviceList}
|
||||
devices=${devices}
|
||||
onSelect=${(id) => navigate('device', id)}
|
||||
onDiscover=${discover}
|
||||
/>
|
||||
`}
|
||||
${page === 'device' && html`
|
||||
<${DeviceDetail}
|
||||
deviceId=${selectedId}
|
||||
devices=${devices}
|
||||
onBack=${() => navigate('devices')}
|
||||
/>
|
||||
`}
|
||||
${page === 'tunein' && html`
|
||||
<${TuneInBrowser} devices=${devices} />
|
||||
`}
|
||||
</main>
|
||||
|
||||
${toast && html`<div class="toast">${toast}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render(html`<${App} />`, document.getElementById('app'));
|
||||
@@ -0,0 +1,80 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
export function Controls({ deviceId, status }) {
|
||||
const np = status?.nowPlaying;
|
||||
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
|
||||
const actualVolume = status?.volume?.ActualVolume ?? 0;
|
||||
const isMuted = status?.volume?.MuteEnabled ?? false;
|
||||
const shuffle = np?.ShuffleSetting ?? 'SHUFFLE_OFF';
|
||||
const repeat = np?.RepeatSetting ?? 'REPEAT_OFF';
|
||||
const actualBass = status?.bass?.TargetBass ?? 0;
|
||||
const hasBass = status?.bass != null;
|
||||
|
||||
const [localVolume, setLocalVolume] = useState(actualVolume);
|
||||
const [localBass, setLocalBass] = useState(actualBass);
|
||||
|
||||
useEffect(() => { setLocalVolume(actualVolume); }, [actualVolume]);
|
||||
useEffect(() => { setLocalBass(actualBass); }, [actualBass]);
|
||||
|
||||
const send = (key) => api.key(deviceId, key);
|
||||
|
||||
function onVolumeChange(e) {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
setLocalVolume(val);
|
||||
api.volume(deviceId, val);
|
||||
}
|
||||
|
||||
function onBassChange(e) {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
setLocalBass(val);
|
||||
api.bass(deviceId, val);
|
||||
}
|
||||
|
||||
function toggleShuffle() {
|
||||
send(shuffle === 'SHUFFLE_ON' ? 'SHUFFLE_OFF' : 'SHUFFLE_ON');
|
||||
}
|
||||
|
||||
function cycleRepeat() {
|
||||
if (repeat === 'REPEAT_OFF') send('REPEAT_ALL');
|
||||
else if (repeat === 'REPEAT_ALL') send('REPEAT_ONE');
|
||||
else send('REPEAT_OFF');
|
||||
}
|
||||
|
||||
const repeatIcon = repeat === 'REPEAT_ONE' ? '🔂' : '🔁';
|
||||
|
||||
return html`
|
||||
<div class="controls">
|
||||
<div class="transport">
|
||||
<button class="ctrl-btn" onClick=${() => send('PREV_TRACK')} title="Previous">⏮</button>
|
||||
<button class="ctrl-btn play-btn" onClick=${() => send(isPlaying ? 'PAUSE' : 'PLAY')}>
|
||||
${isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
<button class="ctrl-btn" onClick=${() => send('NEXT_TRACK')} title="Next">⏭</button>
|
||||
<button class="ctrl-btn ${isMuted ? 'active' : ''}" onClick=${() => send('MUTE')} title="Mute">
|
||||
${isMuted ? '🔇' : '🔊'}
|
||||
</button>
|
||||
<button class="ctrl-btn ${shuffle === 'SHUFFLE_ON' ? 'active' : ''}" onClick=${toggleShuffle} title="Shuffle">🔀</button>
|
||||
<button class="ctrl-btn ${repeat !== 'REPEAT_OFF' ? 'active' : ''}" onClick=${cycleRepeat} title="Repeat">${repeatIcon}</button>
|
||||
</div>
|
||||
<div class="volume-row">
|
||||
<span class="volume-icon">🔈</span>
|
||||
<input type="range" class="volume-slider" min="0" max="100"
|
||||
value=${localVolume} onInput=${onVolumeChange} />
|
||||
<span class="volume-value">${localVolume}</span>
|
||||
</div>
|
||||
${hasBass && html`
|
||||
<div class="bass-row">
|
||||
<span class="bass-label">Bass</span>
|
||||
<input type="range" class="volume-slider" min="-9" max="9"
|
||||
value=${localBass} onInput=${onBassChange} />
|
||||
<span class="volume-value">${localBass > 0 ? '+' : ''}${localBass}</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { h } from 'preact';
|
||||
import htm from 'htm';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function DeviceCard({ id, device, onSelect }) {
|
||||
const { info, status } = device;
|
||||
const np = status?.nowPlaying;
|
||||
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
|
||||
const isStandby = !np || np.Source === 'STANDBY';
|
||||
|
||||
return html`
|
||||
<div class="device-card" onClick=${() => onSelect(id)}>
|
||||
<div class="device-header">
|
||||
<span class="device-name">${info?.Name || id}</span>
|
||||
<span class="device-indicator ${status?.isConnected ? 'online' : 'offline'}"></span>
|
||||
</div>
|
||||
<div class="device-type">${info?.Type || ''}</div>
|
||||
${!isStandby && html`
|
||||
<div class="now-playing-mini">
|
||||
<span class="play-status">${isPlaying ? '▶' : '⏸'}</span>
|
||||
<span class="track-mini">${np.Track || np.StationName || np.Source}</span>
|
||||
${np.Artist && html`<span class="artist-mini"> — ${np.Artist}</span>`}
|
||||
</div>
|
||||
`}
|
||||
${isStandby && html`<div class="standby-label">Standby</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function DeviceList({ devices, onSelect, onDiscover }) {
|
||||
const entries = Object.entries(devices);
|
||||
|
||||
return html`
|
||||
<div class="page-header">
|
||||
<h2>Devices</h2>
|
||||
<button class="btn-secondary" onClick=${onDiscover}>Discover</button>
|
||||
</div>
|
||||
${entries.length === 0
|
||||
? html`
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">◉</div>
|
||||
<p>No devices found on your network.</p>
|
||||
<button class="btn-primary" onClick=${onDiscover}>Start Discovery</button>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="device-grid">
|
||||
${entries.map(([id, device]) => html`
|
||||
<${DeviceCard} key=${id} id=${id} device=${device} onSelect=${onSelect} />
|
||||
`)}
|
||||
</div>`
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function fmt(secs) {
|
||||
if (!secs || secs <= 0) return '0:00';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function NowPlaying({ nowPlaying }) {
|
||||
const [position, setPosition] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const pos = nowPlaying?.Time?.Position ?? 0;
|
||||
setPosition(pos);
|
||||
if (nowPlaying?.PlayStatus !== 'PLAY_STATE') return;
|
||||
const id = setInterval(() => setPosition(p => p + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [nowPlaying?.Time?.Position, nowPlaying?.PlayStatus]);
|
||||
|
||||
if (!nowPlaying || nowPlaying.Source === 'STANDBY') {
|
||||
return html`<div class="now-playing standby">Standby</div>`;
|
||||
}
|
||||
|
||||
const title = nowPlaying.Track || nowPlaying.StationName || nowPlaying.Source;
|
||||
const artURL = nowPlaying.Art?.URL;
|
||||
const isBuffering = nowPlaying.PlayStatus === 'BUFFERING_STATE';
|
||||
const total = nowPlaying.Time?.Total ?? 0;
|
||||
const pct = total > 0 ? Math.min(100, (position / total) * 100) : 0;
|
||||
|
||||
return html`
|
||||
<div class="now-playing">
|
||||
${artURL && html`<img class="album-art" src=${artURL} alt="" />`}
|
||||
<div class="track-info">
|
||||
<div class="track-title">${title}</div>
|
||||
${nowPlaying.Artist && html`<div class="track-artist">${nowPlaying.Artist}</div>`}
|
||||
${nowPlaying.Album && html`<div class="track-album">${nowPlaying.Album}</div>`}
|
||||
<div class="track-meta">
|
||||
<span class="track-source">${nowPlaying.Source}</span>
|
||||
${isBuffering && html`<span class="buffering-badge">Buffering…</span>`}
|
||||
</div>
|
||||
${total > 0 && html`
|
||||
<div class="progress-row">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="progress-time">${fmt(position)} / ${fmt(total)}</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { h } from 'preact';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
TUNEIN: 'TuneIn', SPOTIFY: 'Spotify', AMAZON: 'Amazon',
|
||||
PANDORA: 'Pandora', IHEARTRADIO: 'iHeart', DEEZER: 'Deezer',
|
||||
LOCAL_INTERNET_RADIO: 'Internet Radio',
|
||||
};
|
||||
|
||||
function sourceLabel(source) {
|
||||
return SOURCE_LABELS[source] || source;
|
||||
}
|
||||
|
||||
function PresetSlot({ preset, deviceId, active }) {
|
||||
const item = preset?.ContentItem;
|
||||
const isEmpty = !item;
|
||||
const art = item?.ContainerArt;
|
||||
const name = item?.ItemName || `Preset ${preset?.ID ?? ''}`;
|
||||
|
||||
function select() {
|
||||
if (!isEmpty) api.control(deviceId, 'preset', preset.ID);
|
||||
}
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="preset-slot ${isEmpty ? 'empty' : ''} ${active ? 'active' : ''}"
|
||||
onClick=${select}
|
||||
disabled=${isEmpty}
|
||||
title=${isEmpty ? 'Empty' : name}
|
||||
>
|
||||
${art
|
||||
? html`<img class="preset-art" src=${art} alt="" />`
|
||||
: html`<span class="preset-source-label">${isEmpty ? '—' : sourceLabel(item.Source)}</span>`
|
||||
}
|
||||
<span class="preset-name">${isEmpty ? 'Empty' : name}</span>
|
||||
<span class="preset-num">${preset?.ID ?? ''}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function Presets({ deviceId, status }) {
|
||||
const presets = status?.presets?.Preset ?? [];
|
||||
const currentSource = status?.nowPlaying?.Source;
|
||||
const currentLocation = status?.nowPlaying?.ContentItem?.Location;
|
||||
|
||||
// Build a map for quick lookup, then render slots 1-6
|
||||
const byId = Object.fromEntries(presets.map(p => [p.ID, p]));
|
||||
const slots = [1, 2, 3, 4, 5, 6].map(id => byId[id] ?? { ID: id, ContentItem: null });
|
||||
|
||||
function isActive(preset) {
|
||||
const item = preset.ContentItem;
|
||||
return item && item.Source === currentSource && item.Location === currentLocation;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="presets-section">
|
||||
<h3 class="section-title">Presets</h3>
|
||||
<div class="preset-grid">
|
||||
${slots.map(preset => html`
|
||||
<${PresetSlot}
|
||||
key=${preset.ID}
|
||||
preset=${preset}
|
||||
deviceId=${deviceId}
|
||||
active=${isActive(preset)}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const SOURCE_ICONS = {
|
||||
TUNEIN: '📻', SPOTIFY: '🎵', AMAZON: '🎶', PANDORA: '🎸',
|
||||
DEEZER: '🎵', IHEART: '📻', BLUETOOTH: '📶', AUX: '🔌',
|
||||
LOCAL_MUSIC: '💽', STORED_MUSIC: '💽',
|
||||
};
|
||||
|
||||
export function Recents({ deviceId }) {
|
||||
const [items, setItems] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId) return;
|
||||
api.recents(deviceId).then(resp => {
|
||||
setItems(resp.data?.Items ?? []);
|
||||
}).catch(() => {
|
||||
setItems([]);
|
||||
}).finally(() => setLoading(false));
|
||||
}, [deviceId]);
|
||||
|
||||
if (loading) return html`
|
||||
<div class="recents-section">
|
||||
<div class="section-title">Recents</div>
|
||||
<div class="loading-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
function play(item) {
|
||||
const ci = item.ContentItem;
|
||||
if (!ci?.Location) return;
|
||||
api.play(deviceId, {
|
||||
source: ci.Source,
|
||||
type: ci.Type,
|
||||
location: ci.Location,
|
||||
sourceAccount: ci.SourceAccount,
|
||||
itemName: ci.ItemName,
|
||||
containerArt: ci.ContainerArt,
|
||||
isPresetable: ci.IsPresetable,
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="recents-section">
|
||||
<div class="section-title">Recents</div>
|
||||
<div class="recents-list">
|
||||
${items.map(item => {
|
||||
const ci = item.ContentItem;
|
||||
if (!ci) return null;
|
||||
const icon = SOURCE_ICONS[ci.Source] ?? '♪';
|
||||
return html`
|
||||
<button class="recent-item" key=${item.ID || item.UTCTime} onClick=${() => play(item)}>
|
||||
${ci.ContainerArt
|
||||
? html`<img class="recent-art" src=${ci.ContainerArt} alt="" />`
|
||||
: html`<div class="recent-art recent-art-empty">${icon}</div>`
|
||||
}
|
||||
<div class="recent-info">
|
||||
<span class="recent-name">${ci.ItemName || ci.Source}</span>
|
||||
<span class="recent-source">${ci.Source}</span>
|
||||
</div>
|
||||
<span class="recent-play">▶</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { h } from 'preact';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const SOURCE_ICONS = {
|
||||
TUNEIN: '📻', SPOTIFY: '🎵', AMAZON: '🛒', PANDORA: '🎶',
|
||||
BLUETOOTH: '📶', AUX: '🔌', OPTICAL: '💡', HDMI: '📺',
|
||||
IHEARTRADIO: '❤️', DEEZER: '🎼', LOCAL_INTERNET_RADIO: '📡',
|
||||
AIRPLAY: '📡', PRODUCT: '🔊',
|
||||
};
|
||||
|
||||
export function Sources({ deviceId, status }) {
|
||||
const items = status?.sources?.SourceItem ?? [];
|
||||
const currentSource = status?.nowPlaying?.Source;
|
||||
const currentAccount = status?.nowPlaying?.SourceAccount;
|
||||
|
||||
const ready = items.filter(s => s.Status === 'READY');
|
||||
if (ready.length === 0) return null;
|
||||
|
||||
function select(src) {
|
||||
api.selectSource(deviceId, src.Source, src.SourceAccount ?? '');
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sources-section">
|
||||
<h3 class="section-title">Sources</h3>
|
||||
<div class="source-list">
|
||||
${ready.map(src => {
|
||||
const isActive = src.Source === currentSource &&
|
||||
(!src.SourceAccount || src.SourceAccount === currentAccount);
|
||||
return html`
|
||||
<button
|
||||
key=${src.Source + (src.SourceAccount || '')}
|
||||
class="source-btn ${isActive ? 'active' : ''} ${src.IsLocal ? 'local' : ''}"
|
||||
onClick=${() => select(src)}
|
||||
title=${src.Source}
|
||||
>
|
||||
<span class="source-icon">${SOURCE_ICONS[src.Source] || '🔊'}</span>
|
||||
<span class="source-name">${src.DisplayName || src.Source}</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
// BmxNavResponse has shape { bmx_sections: [{ name, items: [{ name, imageUrl, subtitle, _links }] }] }
|
||||
// _links.bmx_navigate.href = "/v1/navigate/{encodedPath}" — strip prefix for API call
|
||||
// _links.bmx_playback.href = station/track URL, type = "stationurl"|"tracklisturl"
|
||||
|
||||
function navPath(item) {
|
||||
const href = item._links?.bmx_navigate?.href;
|
||||
return href ? href.replace(/^\/v1\/navigate\//, '') : null;
|
||||
}
|
||||
|
||||
function playbackInfo(item) {
|
||||
const link = item._links?.bmx_playback;
|
||||
return link ? { location: link.href, type: link.type || 'stationurl' } : null;
|
||||
}
|
||||
|
||||
function flattenSections(data) {
|
||||
if (!data?.bmx_sections) return [];
|
||||
return data.bmx_sections.flatMap(section =>
|
||||
(section.items || []).map(item => ({ ...item, _sectionName: section.name }))
|
||||
);
|
||||
}
|
||||
|
||||
export function TuneInBrowser({ devices }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [navStack, setNavStack] = useState([{ label: 'TuneIn', path: null }]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pendingPlay, setPendingPlay] = useState(null);
|
||||
|
||||
useEffect(() => { browse(null); }, []);
|
||||
|
||||
async function browse(path) {
|
||||
setLoading(true);
|
||||
const resp = await api.tuneInBrowse(path);
|
||||
setLoading(false);
|
||||
if (resp.success) setItems(flattenSections(resp.data));
|
||||
}
|
||||
|
||||
async function search(q) {
|
||||
if (!q.trim()) return;
|
||||
setLoading(true);
|
||||
const resp = await api.tuneInSearch(q);
|
||||
setLoading(false);
|
||||
if (resp.success) {
|
||||
setNavStack([{ label: 'TuneIn', path: null }, { label: `"${q}"`, path: null }]);
|
||||
setItems(flattenSections(resp.data));
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(item) {
|
||||
const path = navPath(item);
|
||||
const play = playbackInfo(item);
|
||||
|
||||
if (path) {
|
||||
setNavStack(s => [...s, { label: item.name, path }]);
|
||||
browse(path);
|
||||
} else if (play) {
|
||||
setPendingPlay({ ...play, name: item.name, image: item.imageUrl });
|
||||
}
|
||||
}
|
||||
|
||||
function navTo(index) {
|
||||
const stack = navStack.slice(0, index + 1);
|
||||
setNavStack(stack);
|
||||
browse(stack[stack.length - 1].path);
|
||||
}
|
||||
|
||||
async function playOn(deviceId) {
|
||||
await api.tuneInPlay(deviceId, { location: pendingPlay.location, type: pendingPlay.type, name: pendingPlay.name });
|
||||
setPendingPlay(null);
|
||||
}
|
||||
|
||||
const deviceEntries = Object.entries(devices);
|
||||
|
||||
return html`
|
||||
<div class="tunein-browser">
|
||||
<div class="tunein-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
class="tunein-search-input"
|
||||
placeholder="Search stations, podcasts…"
|
||||
value=${searchQuery}
|
||||
onInput=${(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown=${(e) => e.key === 'Enter' && search(searchQuery)}
|
||||
/>
|
||||
<button class="btn-primary" onClick=${() => search(searchQuery)}>Search</button>
|
||||
<button class="btn-secondary" onClick=${() => {
|
||||
setNavStack([{ label: 'TuneIn', path: null }]);
|
||||
setSearchQuery('');
|
||||
browse(null);
|
||||
}}>Browse</button>
|
||||
</div>
|
||||
|
||||
${navStack.length > 1 && html`
|
||||
<nav class="breadcrumb">
|
||||
${navStack.map((entry, i) => html`
|
||||
${i > 0 && html`<span class="breadcrumb-sep">›</span>`}
|
||||
${i < navStack.length - 1
|
||||
? html`<a class="breadcrumb-link" onClick=${() => navTo(i)}>${entry.label}</a>`
|
||||
: html`<span class="breadcrumb-current">${entry.label}</span>`
|
||||
}
|
||||
`)}
|
||||
</nav>
|
||||
`}
|
||||
|
||||
${loading && html`<div class="loading-bar"></div>`}
|
||||
|
||||
<ul class="tunein-list">
|
||||
${items.map((item, i) => {
|
||||
const isNav = !!navPath(item);
|
||||
return html`
|
||||
<li key=${item._links?.self?.href || i} class="tunein-item" onClick=${() => navigate(item)}>
|
||||
${item.imageUrl && html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />`}
|
||||
<div class="tunein-item-info">
|
||||
<span class="tunein-item-name">${item.name}</span>
|
||||
${item.subtitle && html`<span class="tunein-item-desc">${item.subtitle}</span>`}
|
||||
</div>
|
||||
<span class="tunein-item-arrow">${isNav ? '›' : '▶'}</span>
|
||||
</li>
|
||||
`;
|
||||
})}
|
||||
</ul>
|
||||
|
||||
${pendingPlay && html`
|
||||
<div class="overlay" onClick=${() => setPendingPlay(null)}>
|
||||
<div class="device-picker" onClick=${(e) => e.stopPropagation()}>
|
||||
<h3 class="picker-title">Play on device</h3>
|
||||
<p class="picker-item-name">${pendingPlay.name}</p>
|
||||
<div class="picker-devices">
|
||||
${deviceEntries.length === 0 && html`<p class="picker-no-devices">No devices found. Try discovering first.</p>`}
|
||||
${deviceEntries.map(([id, d]) => html`
|
||||
<button class="picker-device-btn" onClick=${() => playOn(id)}>
|
||||
${d.info?.name || id}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<button class="btn-secondary picker-cancel" onClick=${() => setPendingPlay(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
export function Zone({ deviceId, devices }) {
|
||||
const [zone, setZone] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
function refresh() {
|
||||
api.zone(deviceId).then(resp => {
|
||||
if (resp.success) setZone(resp.data);
|
||||
}).finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { refresh(); }, [deviceId]);
|
||||
|
||||
async function addDevice(slaveId) {
|
||||
setShowPicker(false);
|
||||
await api.zoneAdd(deviceId, slaveId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function removeDevice(slaveId) {
|
||||
await api.zoneRemove(deviceId, slaveId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function dissolve() {
|
||||
await api.zoneDissolve(deviceId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function leave() {
|
||||
await api.zoneLeave(deviceId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
if (loading) return html`
|
||||
<div class="zone-section">
|
||||
<div class="section-title">Zone</div>
|
||||
<div class="loading-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!zone) return null;
|
||||
|
||||
// Devices not already in the zone are available to add
|
||||
const zoneIps = new Set([zone.masterIp, ...(zone.members || []).map(m => m.ip)].filter(Boolean));
|
||||
const available = Object.entries(devices || {}).filter(([ip]) => !zoneIps.has(ip));
|
||||
|
||||
const deviceName = (ip) => devices[ip]?.info?.Name ?? ip;
|
||||
|
||||
return html`
|
||||
<div class="zone-section">
|
||||
<div class="section-title">Zone</div>
|
||||
|
||||
${zone.isStandalone && html`
|
||||
<div class="zone-row">
|
||||
<span class="zone-status-label">Standalone</span>
|
||||
${available.length > 0 && html`
|
||||
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Group with…</button>
|
||||
`}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${zone.isMaster && html`
|
||||
<div class="zone-members">
|
||||
<div class="zone-member zone-master-row">
|
||||
<span class="zone-badge master">Master</span>
|
||||
<span class="zone-member-name">${deviceName(deviceId)}</span>
|
||||
</div>
|
||||
${(zone.members || []).map(m => html`
|
||||
<div class="zone-member" key=${m.ip}>
|
||||
<span class="zone-badge slave">Member</span>
|
||||
<span class="zone-member-name">${m.name || m.ip}</span>
|
||||
<button class="btn-icon zone-remove" title="Remove from zone"
|
||||
onClick=${() => removeDevice(m.ip)}>✕</button>
|
||||
</div>
|
||||
`)}
|
||||
<div class="zone-actions">
|
||||
${available.length > 0 && html`
|
||||
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Add speaker</button>
|
||||
`}
|
||||
<button class="btn-secondary zone-btn" onClick=${dissolve}>Dissolve zone</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${zone.isSlave && html`
|
||||
<div class="zone-row">
|
||||
<span class="zone-badge slave">Member</span>
|
||||
<span class="zone-member-name">Zone: ${zone.masterName || zone.masterIp}</span>
|
||||
<button class="btn-secondary zone-btn" onClick=${leave}>Leave zone</button>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${showPicker && html`
|
||||
<div class="overlay" onClick=${() => setShowPicker(false)}>
|
||||
<div class="device-picker" onClick=${e => e.stopPropagation()}>
|
||||
<div class="picker-title">Add to zone</div>
|
||||
<div class="picker-devices">
|
||||
${available.map(([ip, d]) => html`
|
||||
<button class="picker-device-btn" key=${ip} onClick=${() => addDevice(ip)}>
|
||||
${d.info?.Name ?? ip}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<button class="btn-secondary picker-cancel" onClick=${() => setShowPicker(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// Package handlers contains WebSocket handlers for real-time communication.
|
||||
package handlers
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -7,13 +6,13 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// HandleWebSocket handles WebSocket connections for real-time updates
|
||||
// HandleWebSocket handles browser WebSocket connections for real-time updates.
|
||||
func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := app.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
@@ -22,19 +21,16 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Unregister client
|
||||
app.WSMutex.Lock()
|
||||
delete(app.WSClients, conn)
|
||||
app.WSMutex.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Register client
|
||||
app.WSMutex.Lock()
|
||||
app.WSClients[conn] = true
|
||||
app.WSMutex.Unlock()
|
||||
|
||||
// Send initial device list
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
@@ -44,30 +40,20 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{Type: "devices", Data: devices}); err != nil {
|
||||
log.Printf("Failed to send initial data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep connection alive and send updates
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -79,24 +65,19 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Main loop for sending periodic updates
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send periodic status updates
|
||||
for id, device := range app.Devices {
|
||||
if device.Status.IsConnected {
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: id,
|
||||
Data: device.Status,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send status update: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -105,36 +86,31 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDiscover triggers device discovery
|
||||
// HandleAPIDiscover acknowledges a discovery request (actual discovery is triggered by Mount).
|
||||
func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Discovery will be triggered by the main app
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "Discovery started"},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a device
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a SoundTouch device.
|
||||
func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.DeviceConnection) {
|
||||
// Skip WebSocket connection if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wsClient := conn.Client.NewWebSocketClient(nil)
|
||||
|
||||
// Setup event handlers
|
||||
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
|
||||
conn.Status.NowPlaying = &event.NowPlaying
|
||||
conn.Status.LastActivity = time.Now()
|
||||
@@ -155,7 +131,6 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
// Connect WebSocket
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
log.Printf("Failed to connect WebSocket for device %s: %v", deviceID, err)
|
||||
return
|
||||
@@ -166,7 +141,6 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
|
||||
log.Printf("WebSocket connected for device %s", deviceID)
|
||||
|
||||
// Wait for disconnection
|
||||
wsClient.Wait()
|
||||
|
||||
conn.Status.IsConnected = false
|
||||
@@ -174,55 +148,46 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
log.Printf("WebSocket disconnected for device %s", deviceID)
|
||||
}
|
||||
|
||||
// UpdateDeviceStatus fetches current status from device
|
||||
// UpdateDeviceStatus fetches current status from a device.
|
||||
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
|
||||
|
||||
// Get current now playing
|
||||
if nowPlaying, err := conn.Client.GetNowPlaying(); err == nil {
|
||||
conn.Status.NowPlaying = nowPlaying
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get current volume
|
||||
if volume, err := conn.Client.GetVolume(); err == nil {
|
||||
conn.Status.Volume = volume
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get presets
|
||||
if presets, err := conn.Client.GetPresets(); err == nil {
|
||||
conn.Status.Presets = presets
|
||||
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
|
||||
}
|
||||
|
||||
// Get bass (if available)
|
||||
if bass, err := conn.Client.GetBass(); err == nil {
|
||||
conn.Status.Bass = bass
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Mark as connected if we successfully got at least one status
|
||||
conn.Status.IsConnected = statusUpdated
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
|
||||
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
|
||||
// HandleDeviceWebSocket handles per-device WebSocket connections for real-time device-specific updates.
|
||||
func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
@@ -245,31 +210,21 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
log.Printf("Device WebSocket connected for %s", deviceID)
|
||||
|
||||
// Send initial device status
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send initial device status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -281,36 +236,26 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}()
|
||||
|
||||
// Send periodic device status updates
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping to device WebSocket %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send device status update
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send device status update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 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 {
|
||||
realtimeMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "device_realtime",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
@@ -318,12 +263,57 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
"volume": device.Status.Volume,
|
||||
"timestamp": time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(realtimeMessage); err != nil {
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send realtime update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDeviceList sends the updated device list to all connected browser WebSocket clients.
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
app.broadcast(webtypes.WebSocketMessage{Type: "devices", Data: devices})
|
||||
}
|
||||
|
||||
// BroadcastDiscoveryStatus sends discovery progress to all connected browser WebSocket clients.
|
||||
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
app.broadcast(webtypes.WebSocketMessage{
|
||||
Type: "discovery_status",
|
||||
Data: map[string]interface{}{"status": status, "deviceCount": deviceCount},
|
||||
})
|
||||
}
|
||||
|
||||
// broadcast sends a message to all registered WS clients, removing failed ones.
|
||||
// Caller must hold at least a read lock on WSMutex.
|
||||
func (app *WebApp) broadcast(msg webtypes.WebSocketMessage) {
|
||||
var failed []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(msg); err != nil {
|
||||
log.Printf("Failed to broadcast to WebSocket client: %v", err)
|
||||
|
||||
failed = append(failed, client)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range failed {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user