Compare commits

...
4 Commits
16 changed files with 255 additions and 159 deletions
+27 -5
View File
@@ -279,8 +279,8 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
@@ -288,14 +288,36 @@ jobs:
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=pr
- name: Build and push Docker image
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@v7
with:
context: .
target: soundtouch-service
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta-service.outputs.tags }}
labels: ${{ steps.meta-service.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata (tags, labels) for soundtouch-web
id: meta-web
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}-web
tags: |
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=pr
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@v7
with:
context: .
target: soundtouch-web
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+28 -5
View File
@@ -518,8 +518,8 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
@@ -528,14 +528,37 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push Docker image
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@v7
with:
context: .
target: soundtouch-service
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta-service.outputs.tags }}
labels: ${{ steps.meta-service.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata (tags, labels) for soundtouch-web
id: meta-web
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}-web
tags: |
type=semver,pattern={{version}},value=v${{ needs.validate.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@v7
with:
context: .
target: soundtouch-web
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: true
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+24 -8
View File
@@ -24,31 +24,47 @@ RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-service ./cmd/soundtouch-service; \
fi
# Final stage
FROM alpine:3.23
# Build the soundtouch-web
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-web ./cmd/soundtouch-web; \
else \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-web ./cmd/soundtouch-web; \
fi
# soundtouch-service image
FROM alpine:3.23 AS soundtouch-service
# Install necessary runtime dependencies
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
# Copy the binary from the builder stage
COPY --from=builder /soundtouch-service /app/soundtouch-service
# Verify the binary works on the target platform
RUN /app/soundtouch-service version || echo "Binary verification complete"
# Create data directory for persistence
RUN mkdir -p /app/data
# Set environment variables with defaults
ENV PORT=8000
ENV DATA_DIR=/app/data
ENV LOG_PROXY_BODY=false
ENV REDACT_PROXY_LOGS=true
# Expose the service port
EXPOSE 8000
# Run the service
ENTRYPOINT ["/app/soundtouch-service"]
# soundtouch-web image
FROM alpine:3.23 AS soundtouch-web
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /soundtouch-web /app/soundtouch-web
ENV PORT=8080
EXPOSE 8080
ENTRYPOINT ["/app/soundtouch-web"]
+1 -1
View File
@@ -291,7 +291,7 @@ release: clean check build-all
docker-build:
@echo "Building Docker image..."
docker build -t soundtouch-service .
docker build --target soundtouch-service -t soundtouch-service .
docker-run-host:
@echo "Running Docker container..."
+12 -71
View File
@@ -13,6 +13,7 @@ import (
"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"
)
@@ -61,7 +62,7 @@ func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
// HandleAPIDevice returns a specific device as JSON
func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimPrefix(r.URL.Path, "/api/device/")
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
@@ -98,18 +99,9 @@ func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
// HandleAPIControl handles device control commands
func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/control/")
deviceID := chi.URLParam(r, "id")
action := chi.URLParam(r, "action")
parts := strings.Split(path, "/")
if len(parts) < 2 {
app.sendError(w, "Invalid control path", http.StatusBadRequest)
return
}
deviceID := parts[0]
action := parts[1]
// Check for empty device ID
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
@@ -323,19 +315,8 @@ func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode i
// HandleDeviceKey handles sending key commands to devices
func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
app.sendError(w, "POST required", http.StatusMethodNotAllowed)
return
}
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 5 || pathParts[1] != "api" || pathParts[2] != "device-key" {
app.sendError(w, "Invalid path format", http.StatusBadRequest)
return
}
deviceID := pathParts[3]
key := pathParts[4]
deviceID := chi.URLParam(r, "id")
key := chi.URLParam(r, "key")
device, exists := app.Devices[deviceID]
if !exists {
@@ -361,20 +342,9 @@ func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
// HandleDirectVolumeControl handles direct volume setting via URL parameter
func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
app.sendError(w, "POST required", http.StatusMethodNotAllowed)
return
}
deviceID := chi.URLParam(r, "id")
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 5 || pathParts[1] != "api" || pathParts[2] != "device-volume" {
app.sendError(w, "Invalid path format", http.StatusBadRequest)
return
}
deviceID := pathParts[3]
volumeLevel, err := strconv.Atoi(pathParts[4])
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
@@ -404,18 +374,7 @@ func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Requ
// HandleDevicePower handles power toggle commands for devices
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
app.sendError(w, "POST required", http.StatusMethodNotAllowed)
return
}
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-power" {
app.sendError(w, "Invalid path format", http.StatusBadRequest)
return
}
deviceID := pathParts[3]
deviceID := chi.URLParam(r, "id")
device, exists := app.Devices[deviceID]
if !exists {
@@ -442,18 +401,7 @@ func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
// HandleDevicePowerStatus handles lightweight power status check
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
app.sendError(w, "GET required", http.StatusMethodNotAllowed)
return
}
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-power-status" {
app.sendError(w, "Invalid path format", http.StatusBadRequest)
return
}
deviceID := pathParts[3]
deviceID := chi.URLParam(r, "id")
device, exists := app.Devices[deviceID]
if !exists {
@@ -587,14 +535,7 @@ func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
// - /sub/{n}/{encodedURI} → single subsection
// - /profiles/{type}/{id}/{encodedURI} → artist/program profile
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
const navPrefix = "/api/tunein/navigate"
path := r.URL.Path
wildcard := ""
if len(path) > len(navPrefix) {
wildcard = strings.TrimPrefix(path[len(navPrefix):], "/")
}
wildcard := chi.URLParam(r, "*")
var (
resp interface{}
@@ -651,7 +592,7 @@ func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request)
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimPrefix(r.URL.Path, "/api/tunein/play/")
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
@@ -2,6 +2,7 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -12,6 +13,7 @@ import (
"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/go-chi/chi/v5"
)
func createTestApp() *WebApp {
@@ -42,6 +44,14 @@ func createTestApp() *WebApp {
return app
}
func withChiParams(r *http.Request, params map[string]string) *http.Request {
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
func TestNewWebApp(t *testing.T) {
app := NewWebApp()
@@ -102,24 +112,28 @@ func TestHandleAPIDevice(t *testing.T) {
tests := []struct {
name string
path string
chiID string
expectedStatus int
expectSuccess bool
}{
{
name: "valid device",
path: "/api/device/test-device",
chiID: "test-device",
expectedStatus: http.StatusOK,
expectSuccess: true,
},
{
name: "missing device ID",
path: "/api/device/",
chiID: "",
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
{
name: "unknown device",
path: "/api/device/unknown",
chiID: "unknown",
expectedStatus: http.StatusNotFound,
expectSuccess: false,
},
@@ -128,6 +142,9 @@ func TestHandleAPIDevice(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
if tt.chiID != "" {
req = withChiParams(req, map[string]string{"id": tt.chiID})
}
w := httptest.NewRecorder()
app.HandleAPIDevice(w, req)
@@ -157,6 +174,7 @@ func TestHandleAPIControl_InvalidDevice(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/control/unknown-device/play", nil)
req = withChiParams(req, map[string]string{"id": "unknown-device", "action": "play"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
@@ -261,6 +279,7 @@ func TestHandleAPIControl_VolumeValidation(t *testing.T) {
} else {
req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", nil)
}
req = withChiParams(req, map[string]string{"id": "test-device", "action": "volume"})
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
@@ -311,6 +330,7 @@ func TestHandleAPIControl_BassValidation(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/api/control/test-device/bass", strings.NewReader(tt.body))
req = withChiParams(req, map[string]string{"id": "test-device", "action": "bass"})
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
@@ -358,6 +378,7 @@ func TestHandleAPIControl_PresetValidation(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/control/test-device/preset"+tt.query, nil)
req = withChiParams(req, map[string]string{"id": "test-device", "action": "preset"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
@@ -382,6 +403,7 @@ func TestHandleAPIControl_SourceValidation(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/control/test-device/source", nil)
req = withChiParams(req, map[string]string{"id": "test-device", "action": "source"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
@@ -497,6 +519,7 @@ func TestHandleAPIControl_UnsupportedAction(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/control/test-device/unsupported", nil)
req = withChiParams(req, map[string]string{"id": "test-device", "action": "unsupported"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
@@ -545,6 +568,7 @@ func BenchmarkHandleAPIDevices(b *testing.B) {
func BenchmarkHandleAPIDevice(b *testing.B) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/device/test-device", nil)
req = withChiParams(req, map[string]string{"id": "test-device"})
b.ResetTimer()
for i := 0; i < b.N; i++ {
+2 -8
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"log"
"net/http"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
@@ -224,13 +224,7 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection)
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-ws" {
http.Error(w, "Invalid path format", http.StatusBadRequest)
return
}
deviceID := pathParts[3]
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
http.Error(w, "Device ID required", http.StatusBadRequest)
return
+44 -43
View File
@@ -3,10 +3,11 @@ package main
import (
"context"
"embed"
"flag"
"io/fs"
"log"
"net/http"
"os"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
@@ -14,8 +15,12 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/go-chi/chi/v5"
)
//go:embed static
var staticFS embed.FS
var (
port = flag.String("port", "8080", "Web server port")
_ = flag.String("host", "", "Specific SoundTouch device host (optional)")
@@ -56,29 +61,35 @@ func main() {
}()
// Setup HTTP routes
setupRoutes(app, discoveryService)
r := setupRoutes(app, discoveryService)
// Start web server
log.Printf("SoundTouch Web UI starting on http://localhost:%s", *port)
log.Fatal(http.ListenAndServe(":"+*port, nil))
log.Printf("SoundTouch Web UI starting on http://0.0.0.0:%s", *port)
log.Fatal(http.ListenAndServe(":"+*port, r))
}
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
// Static files - try both relative paths
staticDir := "cmd/soundtouch-web/static/"
if _, err := os.Stat(staticDir); os.IsNotExist(err) {
staticDir = "static/"
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)
}
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
// WebSocket endpoint
http.HandleFunc("/ws", app.HandleWebSocket)
r.Get("/ws", app.HandleWebSocket)
// API endpoints
http.HandleFunc("/api/devices", app.HandleAPIDevices)
http.HandleFunc("/api/device/", app.HandleAPIDevice)
http.HandleFunc("/api/discover", func(w http.ResponseWriter, r *http.Request) {
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
@@ -97,39 +108,29 @@ func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscov
}()
})
// Device control endpoints
http.HandleFunc("/api/control/", app.HandleAPIControl)
// 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
http.HandleFunc("/api/tunein/search", app.HandleTuneInSearch)
http.HandleFunc("/api/tunein/navigate", app.HandleTuneInNavigate)
http.HandleFunc("/api/tunein/navigate/", app.HandleTuneInNavigate)
http.HandleFunc("/api/tunein/play/", app.HandlePlayTuneIn)
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 with specific patterns
http.HandleFunc("/api/device-key/", app.HandleDeviceKey)
http.HandleFunc("/api/device-volume/", app.HandleDirectVolumeControl)
http.HandleFunc("/api/device-power/", app.HandleDevicePower)
http.HandleFunc("/api/device-power-status/", app.HandleDevicePowerStatus)
http.HandleFunc("/api/device-ws/", app.HandleDeviceWebSocket)
// 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 specific routes only
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Serve the SPA index.html file for root path
spaPath := staticDir + "index.html"
http.ServeFile(w, r, spaPath)
})
// SPA routes - serve index.html for client-side routing
r.Get("/", serveIndex)
r.Get("/devices", serveIndex)
r.Get("/device/*", serveIndex)
// Additional SPA routes for client-side routing
http.HandleFunc("/devices", func(w http.ResponseWriter, r *http.Request) {
spaPath := staticDir + "index.html"
http.ServeFile(w, r, spaPath)
})
http.HandleFunc("/device/", func(w http.ResponseWriter, r *http.Request) {
spaPath := staticDir + "index.html"
http.ServeFile(w, r, spaPath)
})
return r
}
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
+21
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -11,8 +12,17 @@ import (
"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/go-chi/chi/v5"
)
func withChiParams(r *http.Request, params map[string]string) *http.Request {
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
func TestSPARouting(t *testing.T) {
tests := []struct {
name string
@@ -134,6 +144,8 @@ func TestAPIEndpoints(t *testing.T) {
app.HandleAPIDiscover(w, req)
default:
if strings.HasPrefix(tt.path, "/api/device/") {
deviceID := strings.TrimPrefix(tt.path, "/api/device/")
req = withChiParams(req, map[string]string{"id": deviceID})
app.HandleAPIDevice(w, req)
}
}
@@ -200,6 +212,7 @@ func TestControlAPIValidation(t *testing.T) {
method string
body string
expectedStatus int
chiParams map[string]string
}{
{
name: "missing device ID",
@@ -218,18 +231,21 @@ func TestControlAPIValidation(t *testing.T) {
path: "/api/control/nonexistent/invalid",
method: "GET",
expectedStatus: http.StatusNotFound,
chiParams: map[string]string{"id": "nonexistent", "action": "invalid"},
},
{
name: "nonexistent device",
path: "/api/control/nonexistent/play",
method: "GET",
expectedStatus: http.StatusNotFound,
chiParams: map[string]string{"id": "nonexistent", "action": "play"},
},
{
name: "unknown action with valid device",
path: "/api/control/testdevice/unknownaction",
method: "GET",
expectedStatus: http.StatusBadRequest,
chiParams: map[string]string{"id": "testdevice", "action": "unknownaction"},
},
}
@@ -251,6 +267,9 @@ func TestControlAPIValidation(t *testing.T) {
} else {
req = httptest.NewRequest(tt.method, tt.path, nil)
}
if tt.chiParams != nil {
req = withChiParams(req, tt.chiParams)
}
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
@@ -319,6 +338,8 @@ func TestJSONAPIConsistency(t *testing.T) {
app.HandleAPIDevices(w, req)
default:
if strings.HasPrefix(endpoint, "/api/device/") {
deviceID := strings.TrimPrefix(endpoint, "/api/device/")
req = withChiParams(req, map[string]string{"id": deviceID})
app.HandleAPIDevice(w, req)
}
}
+3 -1
View File
@@ -1,6 +1,8 @@
services:
soundtouch-service:
build: .
build:
context: .
target: soundtouch-service
volumes:
- ./tests/integration/testdata:/app/data
environment:
+1
View File
@@ -755,6 +755,7 @@ type FullResponseSource struct {
Username string `json:"username" xml:"username"`
Account string `json:"account,omitempty" xml:"account,attr,omitempty"`
SourceLabel string `json:"source_label" xml:"-"`
ProviderLabel string `json:"provider_label,omitempty" xml:"-"`
}
// FullResponsePreset represents a preset specifically for the /full response.
+16
View File
@@ -265,6 +265,22 @@ func GetProviderName(providerID string) string {
return providerID
}
// GetProviderLabel returns the user-friendly label for a provider ID (e.g. "TuneIn Radio", "Spotify").
func GetProviderLabel(providerID string) string {
id, err := strconv.Atoi(providerID)
if err != nil {
return ""
}
for _, p := range StaticProviders {
if p.ID == id {
return p.Label
}
}
return ""
}
// GetProviders returns a list of known source provider names.
func GetProviders() []string {
var providers []string
+27
View File
@@ -1330,6 +1330,25 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
} `xml:"sourceKey"`
}
// Deduplicate by ID before saving; first occurrence wins to preserve established data
seen := make(map[string]bool)
deduped := make([]models.ConfiguredSource, 0, len(sources))
for i := range sources {
s := &sources[i]
if s.ID != "" {
if seen[s.ID] {
continue
}
seen[s.ID] = true
}
deduped = append(deduped, *s)
}
sources = deduped
// Ensure SourceKey is populated from legacy fields if necessary before saving
// and map to persistentSource to avoid custom MarshalXML for disk storage
persistSources := make([]persistentSource, len(sources))
@@ -1591,6 +1610,14 @@ func (ds *DataStore) GetETagForPresets(account, device string) int64 {
return info.ModTime().UnixNano() / int64(time.Millisecond)
}
// HasConfiguredSources reports whether a Sources.xml file exists for the given account and device.
func (ds *DataStore) HasConfiguredSources(account, device string) bool {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
_, err := os.Stat(path)
return err == nil
}
// GetETagForSources returns the ETag (modification time) for the sources file for a specific device.
func (ds *DataStore) GetETagForSources(account, device string) int64 {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
@@ -269,6 +269,7 @@ func mapToFullResponseSource(src *models.ConfiguredSource) models.FullResponseSo
UpdatedOn: src.UpdatedOn,
Account: src.SourceKey.Account,
SourceLabel: constants.GetSourceLabel(src.Type),
ProviderLabel: constants.GetProviderLabel(src.SourceProviderID),
SourceSettings: src.SourceSettings,
}
fs.Credential.Value = src.Secret
+14 -10
View File
@@ -569,12 +569,14 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
return
}
// 8. Ensure default sources exist if missing
if sources, err := s.ds.GetConfiguredSources(accountID, deviceID); err == nil {
log.Printf("Creating default Sources.xml for device %s", deviceID)
// 8. Create default Sources.xml only when no sources file exists yet
if !s.ds.HasConfiguredSources(accountID, deviceID) {
if sources, err := s.ds.GetConfiguredSources(accountID, deviceID); err == nil {
log.Printf("Creating default Sources.xml for device %s", deviceID)
if err := s.ds.SaveConfiguredSources(accountID, deviceID, sources); err != nil {
log.Printf("Failed to save default sources for %s: %v", deviceID, err)
if err := s.ds.SaveConfiguredSources(accountID, deviceID, sources); err != nil {
log.Printf("Failed to save default sources for %s: %v", deviceID, err)
}
}
}
@@ -620,12 +622,14 @@ func (s *Server) handleDiscoveredDeviceFallback(d models.DiscoveredDevice) {
return
}
// Ensure default sources exist if missing
if sources, err := s.ds.GetConfiguredSources(accountID, deviceID); err == nil {
log.Printf("Creating default Sources.xml for device %s (fallback)", deviceID)
// Create default Sources.xml only when no sources file exists yet
if !s.ds.HasConfiguredSources(accountID, deviceID) {
if sources, err := s.ds.GetConfiguredSources(accountID, deviceID); err == nil {
log.Printf("Creating default Sources.xml for device %s (fallback)", deviceID)
if err := s.ds.SaveConfiguredSources(accountID, deviceID, sources); err != nil {
log.Printf("Failed to save default sources for %s: %v", deviceID, err)
if err := s.ds.SaveConfiguredSources(accountID, deviceID, sources); err != nil {
log.Printf("Failed to save default sources for %s: %v", deviceID, err)
}
}
}
+10 -7
View File
@@ -707,8 +707,8 @@ async function fetchAccountDetails(accountId) {
itemName = p.name || (p.source ? (p.source.source_label || p.source.name || p.source.type) : "Unknown");
if (p.source) {
const s = p.source;
const name = s.source_label || s.source_name || s.name || s.type;
const account = (s.account && s.account !== s.username) ? ` [${s.account}]` : "";
const name = s.provider_label || s.display_name || s.source_name || s.name || s.type;
const account = (s.account && s.account !== s.username && s.account !== name) ? ` [${s.account}]` : "";
const finalName = name || s.type || "Unknown Source";
if (finalName) {
sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${finalName}${account}</small>`;
@@ -732,14 +732,17 @@ async function fetchAccountDetails(accountId) {
let sourceLabel = "";
if (r.source) {
const s = r.source;
const sName = s.source_label || s.source_name || s.name || s.type;
const account = (s.account && s.account !== s.username) ? ` [${s.account}]` : "";
const sName = s.provider_label || s.display_name || s.source_name || s.name || s.type;
const account = (s.account && s.account !== s.username && s.account !== sName) ? ` [${s.account}]` : "";
const finalSName = sName || s.type || "Unknown Source";
if (finalSName) {
sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${finalSName}${account}</small>`;
}
}
return `<li>${name}${sourceLabel} <br><small style="color:#888">${r.created_on ? new Date(r.created_on * 1000).toLocaleString() : 'N/A'}</small></li>`;
const dateRaw = r.last_played_at || r.created_on;
const dateObj = dateRaw ? (isNaN(Number(dateRaw)) ? new Date(dateRaw) : new Date(Number(dateRaw) * 1000)) : null;
const dateStr = dateObj ? dateObj.toLocaleString('sv-SE') : 'N/A'; // sv-SE produces YYYY-MM-DD HH:MM:SS with 24h time
return `<li>${name}${sourceLabel} <br><small style="color:#888">${dateStr}</small></li>`;
}).join("") : "<li>No recents</li>"}
</ul>
</div>
@@ -750,9 +753,9 @@ async function fetchAccountDetails(accountId) {
<h5 style="margin: 0 0 5px 0">Configured Sources</h5>
<div style="display: flex; flex-wrap: wrap; gap: 5px">
${device.sources ? device.sources.filter(s => (s.source_label || s.source_name || s.name || s.type)).map(s => {
const sourceName = s.source_label || s.source_name || s.name || s.type;
const sourceName = s.provider_label || s.display_name || s.source_name || s.name || s.type;
const usernameSuffix = (s.username && s.username !== "Local") ? ` (${s.username})` : "";
const accountSuffix = (s.account && s.account !== s.username) ? ` [${s.account}]` : "";
const accountSuffix = (s.account && s.account !== s.username && s.account !== sourceName) ? ` [${s.account}]` : "";
return `
<span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${s.type}">
${sourceName}${usernameSuffix}${accountSuffix}