mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdaf9f0c0a | ||
|
|
174d087b8e | ||
|
|
066e381737 | ||
|
|
522492177d |
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
services:
|
||||
soundtouch-service:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
volumes:
|
||||
- ./tests/integration/testdata:/app/data
|
||||
environment:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user