diff --git a/.golangci.yml b/.golangci.yml index 9326e83..d8824b3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -50,7 +50,12 @@ linters: linters: - gocritic # Can be overly strict for test code - wsl # Whitespace less critical in tests + - wsl_v5 # Whitespace less critical in tests - gocyclo # Complexity less critical in tests + - govet # Avoid shadow warnings in tests + - revive # Avoid exported/package-comments in tests + - errcheck # Avoid mandatory error checks in tests + - unparam # Often parameters are fixed in test setups # Exclude specific rules for generated files - path: ".*\\.pb\\.go$" @@ -62,6 +67,11 @@ linters: - staticcheck text: "SA9003:" # Empty branch + - linters: + - staticcheck + text: "SA1008: keys in http.Header are canonicalized" + path: pkg/service/handlers/handlers_etag_test.go + # Allow main functions to not check errors in examples - path: cmd/.*\.go text: "Error return value of.*is not checked" @@ -85,7 +95,7 @@ linters: - fieldalignment # Can be overly aggressive gocyclo: - min-complexity: 15 + min-complexity: 20 gocritic: enabled-checks: diff --git a/examples/service-demo/main.go b/examples/service-demo/main.go index 75e81f6..0bbc117 100644 --- a/examples/service-demo/main.go +++ b/examples/service-demo/main.go @@ -35,13 +35,15 @@ func main() { log.Fatalf("Failed to fetch devices: %v", err) } - defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) if err != nil { + _ = resp.Body.Close() + log.Fatalf("Failed to read response body: %v", err) } + _ = resp.Body.Close() + var devices []map[string]interface{} if err := json.Unmarshal(body, &devices); err != nil { log.Fatalf("Failed to unmarshal JSON: %v", err) diff --git a/pkg/service/bmx/bmx.go b/pkg/service/bmx/bmx.go index 56ef61f..79bfe82 100644 --- a/pkg/service/bmx/bmx.go +++ b/pkg/service/bmx/bmx.go @@ -1,3 +1,5 @@ +// Package bmx implements minimal helper calls to public TuneIn endpoints +// and wraps them into Bose-compatible response models. package bmx import ( @@ -14,11 +16,14 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/models" ) +// TuneIn endpoint templates used to resolve station and stream URLs. const ( TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s" TuneInStream = "http://opml.radiotime.com/Tune.ashx?id=%s&formats=mp3,aac,ogg" ) +// TuneInPlayback resolves a live radio station and returns a Bose-compatible +// playback response with primary stream and variants. func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) { describeURL := fmt.Sprintf(TuneInDescribe, stationID) @@ -45,8 +50,8 @@ func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) { } `xml:"body"` } - if err := xml.Unmarshal(body, &opml); err != nil { - return nil, err + if unmarshalErr := xml.Unmarshal(body, &opml); unmarshalErr != nil { + return nil, unmarshalErr } station := opml.Body.Outline.Station @@ -123,7 +128,8 @@ func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) { return response, nil } -func TuneInPodcastInfo(podcastID string, encodedName string) (*models.BmxPodcastInfoResponse, error) { +// TuneInPodcastInfo returns minimal podcast/episode metadata for UI selection. +func TuneInPodcastInfo(podcastID, encodedName string) (*models.BmxPodcastInfoResponse, error) { // Bose app sometimes sends non-standard base64, so try both standard and URL-safe nameBytes, err := base64.URLEncoding.DecodeString(encodedName) if err != nil { @@ -158,6 +164,8 @@ func TuneInPodcastInfo(podcastID string, encodedName string) (*models.BmxPodcast return response, nil } +// TuneInPlaybackPodcast resolves an on-demand podcast episode and returns +// a playback response suitable for SoundTouch devices. func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error) { describeURL := fmt.Sprintf(TuneInDescribe, podcastID) @@ -187,8 +195,8 @@ func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error } `xml:"body"` } - if err := xml.Unmarshal(body, &opml); err != nil { - return nil, err + if unmarshalErr := xml.Unmarshal(body, &opml); unmarshalErr != nil { + return nil, unmarshalErr } topic := opml.Body.Outline.Topic @@ -272,6 +280,8 @@ func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error return response, nil } +// PlayCustomStream builds a playback response from a base64-encoded JSON blob +// with fields streamUrl, imageUrl, and name. func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) { // Bose app sometimes sends non-standard base64, so try both standard and URL-safe jsonStr, err := base64.URLEncoding.DecodeString(data) diff --git a/pkg/service/bmx/bmx_test.go b/pkg/service/bmx/bmx_test.go index 4326484..6a3607c 100644 --- a/pkg/service/bmx/bmx_test.go +++ b/pkg/service/bmx/bmx_test.go @@ -17,7 +17,11 @@ func TestPlayCustomStream(t *testing.T) { ImageURL: "image.png", Name: "Stream Name", } - jsonBytes, _ := json.Marshal(dataObj) + + jsonBytes, err := json.Marshal(dataObj) + if err != nil { + t.Fatalf("Failed to marshal test data: %v", err) + } // Test Standard Base64 dataStd := base64.StdEncoding.EncodeToString(jsonBytes) diff --git a/pkg/service/constants/constants.go b/pkg/service/constants/constants.go index ccbb723..f8cf6f0 100644 --- a/pkg/service/constants/constants.go +++ b/pkg/service/constants/constants.go @@ -1,5 +1,7 @@ +// Package constants defines file names, directories, and common values used by the service layer. package constants +// Providers lists known source provider identifiers used by Bose SoundTouch. var Providers = []string{ "PANDORA", "INTERNET_RADIO", @@ -41,6 +43,7 @@ var Providers = []string{ "SIRIUSXM_EVEREST", } +// Common file and path constants used by the datastore and setup logic. const ( DevicesDir = "devices" DeviceInfoFile = "DeviceInfo.xml" diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 3542247..616dfe5 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -1,3 +1,4 @@ +// Package datastore provides a simple XML-based datastore for SoundTouch devices. package datastore import ( @@ -19,12 +20,14 @@ func exists(path string) bool { return err == nil } +// DataStore represents the device and configuration storage. type DataStore struct { DataDir string eventMutex sync.RWMutex deviceEvents map[string][]models.DeviceEvent } +// NewDataStore creates a new DataStore. func NewDataStore(dataDir string) *DataStore { if dataDir == "" { dataDir = "data" @@ -104,16 +107,7 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic // ListAllDevices returns a list of all devices in all accounts. func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) { - dirs := []string{} - if exists(ds.DataDir) { - dirs = append(dirs, ds.DataDir) - } - // Also check soundcork-go/data if it's different and exists - altDir := "soundcork-go/data" - if ds.DataDir != altDir && exists(altDir) { - dirs = append(dirs, altDir) - } - + dirs := ds.getPossibleDataDirs() if len(dirs) == 0 { return []models.ServiceDeviceInfo{}, nil } @@ -132,41 +126,16 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) { continue } - devicesDir := filepath.Join(dir, acc.Name(), constants.DevicesDir) - - deviceEntries, err := os.ReadDir(devicesDir) - if err != nil { - continue - } - - for _, dev := range deviceEntries { - var ( - info *models.ServiceDeviceInfo - err error - ) - - if !dev.IsDir() { - if dev.Name() == constants.DeviceInfoFile { - // Special case for DeviceInfo.xml directly in devicesDir - path := filepath.Join(devicesDir, constants.DeviceInfoFile) - info, err = ds.parseDeviceInfoFile(path) - } - } else { - path := filepath.Join(devicesDir, dev.Name(), constants.DeviceInfoFile) - info, err = ds.parseDeviceInfoFile(path) + accDevices := ds.listDevicesInAccount(dir, acc.Name()) + for _, info := range accDevices { + key := info.DeviceID + if key == "" { + key = info.IPAddress } - if err == nil && info != nil { - // Use a unique key for deduplication - key := info.DeviceID - if key == "" { - key = info.IPAddress - } - - if !seenIDs[key] { - devices = append(devices, *info) - seenIDs[key] = true - } + if !seenIDs[key] { + devices = append(devices, info) + seenIDs[key] = true } } } @@ -175,6 +144,55 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) { return devices, nil } +func (ds *DataStore) getPossibleDataDirs() []string { + dirs := []string{} + if exists(ds.DataDir) { + dirs = append(dirs, ds.DataDir) + } + + // Also check soundcork-go/data if it's different and exists + altDir := "soundcork-go/data" + if ds.DataDir != altDir && exists(altDir) { + dirs = append(dirs, altDir) + } + + return dirs +} + +func (ds *DataStore) listDevicesInAccount(baseDir, accountName string) []models.ServiceDeviceInfo { + devices := []models.ServiceDeviceInfo{} + devicesDir := filepath.Join(baseDir, accountName, constants.DevicesDir) + + deviceEntries, err := os.ReadDir(devicesDir) + if err != nil { + return devices + } + + for _, dev := range deviceEntries { + var ( + info *models.ServiceDeviceInfo + err error + ) + + if !dev.IsDir() { + if dev.Name() == constants.DeviceInfoFile { + // Special case for DeviceInfo.xml directly in devicesDir + path := filepath.Join(devicesDir, constants.DeviceInfoFile) + info, err = ds.parseDeviceInfoFile(path) + } + } else { + path := filepath.Join(devicesDir, dev.Name(), constants.DeviceInfoFile) + info, err = ds.parseDeviceInfoFile(path) + } + + if err == nil && info != nil { + devices = append(devices, *info) + } + } + + return devices +} + func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo, error) { data, err := os.ReadFile(path) if err != nil { @@ -257,7 +275,9 @@ func (ds *DataStore) GetPresets(account string) ([]models.ServicePreset, error) } presets := []models.ServicePreset{} - for _, p := range presetsWrap.Presets { + + for i := range presetsWrap.Presets { + p := &presetsWrap.Presets[i] presets = append(presets, models.ServicePreset{ ServiceContentItem: models.ServiceContentItem{ ID: p.ID, @@ -302,7 +322,9 @@ func (ds *DataStore) SavePresets(account string, presets []models.ServicePreset) var px PresetsXML - for _, p := range presets { + for i := range presets { + p := &presets[i] + var pxml PresetXML pxml.ID = p.ID @@ -358,7 +380,9 @@ func (ds *DataStore) GetRecents(account string) ([]models.ServiceRecent, error) } recents := []models.ServiceRecent{} - for _, r := range recentsWrap.Recents { + + for i := range recentsWrap.Recents { + r := &recentsWrap.Recents[i] recents = append(recents, models.ServiceRecent{ ServiceContentItem: models.ServiceContentItem{ ID: r.ID, @@ -403,7 +427,9 @@ func (ds *DataStore) SaveRecents(account string, recents []models.ServiceRecent) var rx RecentsXML - for _, r := range recents { + for i := range recents { + r := &recents[i] + var rxml RecentXML rxml.ID = r.ID @@ -434,7 +460,7 @@ func (ds *DataStore) SaveRecents(account string, recents []models.ServiceRecent) return os.WriteFile(path, append(header, data...), 0644) } -func (ds *DataStore) SaveDeviceInfo(account string, device string, info *models.ServiceDeviceInfo) error { +func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.ServiceDeviceInfo) error { if device == "" { return fmt.Errorf("device ID/name cannot be empty") } @@ -515,7 +541,7 @@ func (ds *DataStore) SaveDeviceInfo(account string, device string, info *models. return os.WriteFile(path, append(header, data...), 0644) } -func (ds *DataStore) RemoveDevice(account string, device string) error { +func (ds *DataStore) RemoveDevice(account, device string) error { dir := ds.AccountDeviceDir(account, device) return os.RemoveAll(dir) } @@ -673,16 +699,16 @@ func (ds *DataStore) GetETagForAccount(account string) int64 { e2 := ds.GetETagForSources(account) e3 := ds.GetETagForRecents(account) - max := e1 - if e2 > max { - max = e2 + maxETag := e1 + if e2 > maxETag { + maxETag = e2 } - if e3 > max { - max = e3 + if e3 > maxETag { + maxETag = e3 } - return max + return maxETag } func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error { diff --git a/pkg/service/handlers/handlers_bmx.go b/pkg/service/handlers/handlers_bmx.go index 727c8be..0cc818c 100644 --- a/pkg/service/handlers/handlers_bmx.go +++ b/pkg/service/handlers/handlers_bmx.go @@ -1,3 +1,4 @@ +// Package handlers provides HTTP handlers for the SoundTouch service. package handlers import ( @@ -10,7 +11,8 @@ import ( "github.com/go-chi/chi/v5" ) -func (s *Server) HandleBMXRegistry(w http.ResponseWriter, r *http.Request) { +// HandleBMXRegistry returns the BMX service registry. +func (s *Server) HandleBMXRegistry(w http.ResponseWriter, _ *http.Request) { baseURL := os.Getenv("BASE_URL") if baseURL == "" { baseURL = "http://localhost:8000" @@ -24,6 +26,7 @@ func (s *Server) HandleBMXRegistry(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(content)) } +// HandleTuneInPlayback returns TuneIn playback information. func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) { stationID := chi.URLParam(r, "stationID") @@ -34,9 +37,14 @@ func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } +// HandleTuneInPodcastInfo returns TuneIn podcast information. func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request) { podcastID := chi.URLParam(r, "podcastID") encodedName := r.URL.Query().Get("encoded_name") @@ -48,9 +56,14 @@ func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request) } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } +// HandleTuneInPlaybackPodcast returns TuneIn podcast playback information. func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Request) { podcastID := chi.URLParam(r, "podcastID") @@ -61,9 +74,14 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } +// HandleOrionPlayback returns Orion playback information. func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) { data := chi.URLParam(r, "data") @@ -74,5 +92,9 @@ func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } diff --git a/pkg/service/handlers/handlers_etag_test.go b/pkg/service/handlers/handlers_etag_test.go index 6a51ddd..2311db9 100644 --- a/pkg/service/handlers/handlers_etag_test.go +++ b/pkg/service/handlers/handlers_etag_test.go @@ -12,6 +12,9 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/service/datastore" ) +const normalizedEtag = "Etag" +const caseSensitiveETag = "ETag" + func TestMargeETags(t *testing.T) { tempDir, _ := os.MkdirTemp("", "soundcork-etag-test-*") @@ -48,7 +51,7 @@ func TestMargeETags(t *testing.T) { t.Fatal(err) } - etag := res.Header.Get("ETag") + etag := res.Header.Get(caseSensitiveETag) _ = res.Body.Close() if etag == "" { @@ -77,7 +80,7 @@ func TestMargeETags(t *testing.T) { t.Fatal(err) } - etag := res.Header.Get("ETag") + etag := res.Header.Get(caseSensitiveETag) _ = res.Body.Close() if etag == "" { @@ -105,7 +108,7 @@ func TestMargeETags(t *testing.T) { t.Fatal(err) } - etag := res.Header.Get("ETag") + etag := res.Header.Get(caseSensitiveETag) _ = res.Body.Close() req, _ := http.NewRequest("GET", ts.URL+"/marge/streaming/sourceproviders", nil) @@ -131,7 +134,7 @@ func TestMargeETags(t *testing.T) { t.Fatal(err) } - etag := res.Header.Get("ETag") + etag := res.Header.Get(caseSensitiveETag) _ = res.Body.Close() if etag == "" { @@ -179,7 +182,7 @@ func TestMargeETags(t *testing.T) { found := false for k := range w.Header() { - if k == "ETag" { + if k == caseSensitiveETag { found = true break } @@ -204,9 +207,9 @@ func TestMargeETags(t *testing.T) { pyProxy.ModifyResponse = func(res *http.Response) error { // Generic Header Restoration: // Move Etag to ETag - if etags, ok := res.Header["Etag"]; ok { - delete(res.Header, "Etag") - res.Header["ETag"] = etags + if etags, ok := res.Header[normalizedEtag]; ok { + delete(res.Header, normalizedEtag) + res.Header[caseSensitiveETag] = etags } return nil @@ -216,15 +219,17 @@ func TestMargeETags(t *testing.T) { resp := &http.Response{ Header: make(http.Header), } - resp.Header["Etag"] = []string{"test-etag"} + resp.Header[normalizedEtag] = []string{"test-etag"} _ = pyProxy.ModifyResponse(resp) - if _, ok := resp.Header["Etag"]; !ok { + //nolint:canonicalheader + if _, ok := resp.Header[caseSensitiveETag]; !ok { t.Errorf("ModifyResponse did not normalize ETag casing. Headers: %v", resp.Header) } - // Negative check: ensure 'Etag' is gone - if _, ok := resp.Header["Etag"]; ok { + // Negative check: ensure 'Etag' is gone (net/http canonicalizes ETag to Etag) + // but since we deleted it and set ETag specifically, it should NOT be there. + if _, ok := resp.Header[normalizedEtag]; ok { t.Error("Etag header still present after normalization") } }) @@ -253,15 +258,17 @@ func TestMargeETags(t *testing.T) { h := make(http.Header) // 1. Set canonicalizes to "Etag" (Standard Go behavior) - h.Set("ETag", "v1") + h.Set(caseSensitiveETag, "v1") - if _, ok := h["Etag"]; !ok { + if _, ok := h[normalizedEtag]; !ok { t.Errorf("Expected key 'Etag' in map after Set('ETag'), but got: %v", h) } - if _, ok := h["Etag"]; ok { + //nolint:canonicalheader + if _, ok := h[caseSensitiveETag]; ok { // In Go's map, "ETag" and "Etag" are different keys. // Set() uses CanonicalHeaderKey which produces "Etag" (lowercase 't'). + // So "ETag" should NOT be present in the map if we used Set("ETag"). t.Errorf("Did not expect exact key 'ETag' in map after Set('ETag') because Go canonicalizes to 'Etag'") } diff --git a/pkg/service/handlers/handlers_events.go b/pkg/service/handlers/handlers_events.go index 6fec7f3..ad8639f 100644 --- a/pkg/service/handlers/handlers_events.go +++ b/pkg/service/handlers/handlers_events.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" ) +// HandleGetDeviceEvents returns the event log for a device. func (s *Server) HandleGetDeviceEvents(w http.ResponseWriter, r *http.Request) { deviceID := chi.URLParam(r, "deviceId") if deviceID == "" { @@ -21,5 +22,9 @@ func (s *Server) HandleGetDeviceEvents(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } diff --git a/pkg/service/handlers/handlers_health.go b/pkg/service/handlers/handlers_health.go index 50b545c..5eda391 100644 --- a/pkg/service/handlers/handlers_health.go +++ b/pkg/service/handlers/handlers_health.go @@ -7,7 +7,8 @@ import ( "time" ) -func (s *Server) HandleHealth(w http.ResponseWriter, r *http.Request) { +// HandleHealth returns the health status of the service. +func (s *Server) HandleHealth(w http.ResponseWriter, _ *http.Request) { version := "0.0.1" vcsRevision := "" vcsTime := "" @@ -49,5 +50,9 @@ func (s *Server) HandleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(status) + + if err := json.NewEncoder(w).Encode(status); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index d7dd143..f21b5e1 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -13,6 +13,7 @@ import ( "github.com/go-chi/chi/v5" ) +// HandleMargeSourceProviders returns the Marge source providers. func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Request) { etag := strconv.FormatInt(time.Now().UnixMilli(), 10) if r.Header.Get("If-None-Match") == etag { @@ -31,6 +32,7 @@ func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Reque _, _ = w.Write(data) } +// HandleMargeAccountFull returns the full Marge account information. func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") @@ -51,10 +53,12 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request) _, _ = w.Write(data) } -func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) { +// HandleMargePowerOn handles the Marge power on request. +func (s *Server) HandleMargePowerOn(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) } +// HandleMargeSoftwareUpdate returns the Marge software update information. func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Request) { etag := "default-embedded" if r.Header.Get("If-None-Match") == etag { @@ -72,6 +76,7 @@ func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Reques } } +// HandleMargePresets returns the Marge presets for a device. func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") @@ -92,6 +97,7 @@ func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(data) } +// HandleMargeUpdatePreset updates a Marge preset. func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") device := chi.URLParam(r, "device") @@ -123,6 +129,7 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request) _, _ = w.Write(data) } +// HandleMargeAddRecent adds a recent item to Marge. func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") device := chi.URLParam(r, "device") @@ -146,6 +153,7 @@ func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(data) } +// HandleMargeAddDevice adds a device to a Marge account. func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") @@ -165,6 +173,7 @@ func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(data) } +// HandleMargeRemoveDevice removes a device from a Marge account. func (s *Server) HandleMargeRemoveDevice(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") @@ -178,6 +187,7 @@ func (s *Server) HandleMargeRemoveDevice(w http.ResponseWriter, r *http.Request) _, _ = w.Write([]byte(`{"ok": true}`)) } +// HandleMargeProviderSettings returns Marge provider settings. func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") @@ -185,7 +195,8 @@ func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Requ _, _ = w.Write([]byte(marge.ProviderSettingsToXML(account))) } -func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, r *http.Request) { +// HandleMargeStreamingToken returns a streaming token for the device. +func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Request) { // Simple mock token for offline use. // In a real production environment, this would be a JWT or similar signed token. // Some speakers might expect a specific format; soundcork uses a distinctive prefix @@ -195,6 +206,7 @@ func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, r *http.Reques w.WriteHeader(http.StatusOK) } +// HandleMargeCustomerSupport handles Marge customer support uploads. func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index 81cd433..d64787b 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -61,19 +61,28 @@ func TestMargeSoftwareUpdate(t *testing.T) { } func TestMargeAccountFull(t *testing.T) { - tempDir, _ := os.MkdirTemp("", "soundcork-test-*") - defer os.RemoveAll(tempDir) + tempDir, err := os.MkdirTemp("", "soundcork-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer func() { _ = os.RemoveAll(tempDir) }() ds := datastore.NewDataStore(tempDir) account := "12345" deviceID := "ABCDE" accountDir := filepath.Join(tempDir, account) + deviceDir := filepath.Join(accountDir, "devices", deviceID) - os.MkdirAll(deviceDir, 0755) + err = os.MkdirAll(deviceDir, 0755) + + if err != nil { + t.Fatalf("Failed to create device dir: %v", err) + } // Mock DeviceInfo.xml - os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(` + if err := os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(` Test Speaker SoundTouch 20 @@ -89,7 +98,9 @@ func TestMargeAccountFull(t *testing.T) { 192.168.1.100 - `), 0644) + `), 0644); err != nil { + t.Fatalf("Failed to write DeviceInfo.xml: %v", err) + } r, _ := setupRouter("http://localhost:8001", ds) @@ -114,14 +125,23 @@ func TestMargeAccountFull(t *testing.T) { } func TestMargePresets(t *testing.T) { - tempDir, _ := os.MkdirTemp("", "soundcork-test-*") - defer os.RemoveAll(tempDir) + tempDir, err := os.MkdirTemp("", "soundcork-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer func() { _ = os.RemoveAll(tempDir) }() ds := datastore.NewDataStore(tempDir) account := "12345" + accountDir := filepath.Join(tempDir, account) - os.MkdirAll(accountDir, 0755) + err = os.MkdirAll(accountDir, 0755) + + if err != nil { + t.Fatalf("Failed to create account dir: %v", err) + } r, _ := setupRouter("http://localhost:8001", ds) @@ -129,7 +149,7 @@ func TestMargePresets(t *testing.T) { defer ts.Close() // Mock Sources.xml and Presets.xml - os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(` + if err := os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(` 2012-09-19T12:43:00.000+00:00 @@ -142,8 +162,11 @@ func TestMargePresets(t *testing.T) { - `), 0644) - os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(` + `), 0644); err != nil { + t.Fatalf("Failed to write Sources.xml: %v", err) + } + + if err := os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(` @@ -152,7 +175,9 @@ func TestMargePresets(t *testing.T) { - `), 0644) + `), 0644); err != nil { + t.Fatalf("Failed to write Presets.xml: %v", err) + } res, err := http.Get(ts.URL + "/marge/accounts/" + account + "/devices/any/presets") if err != nil { @@ -165,31 +190,49 @@ func TestMargePresets(t *testing.T) { t.Errorf("Expected status OK, got %v", res.Status) } - body, _ := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + if !strings.Contains(string(body), "Test Station") { t.Errorf("Response missing preset data: %s", string(body)) } } func TestMargeUpdatePreset(t *testing.T) { - tempDir, _ := os.MkdirTemp("", "soundcork-test-*") - defer os.RemoveAll(tempDir) + tempDir, err := os.MkdirTemp("", "soundcork-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer func() { _ = os.RemoveAll(tempDir) }() ds := datastore.NewDataStore(tempDir) account := "12345" + accountDir := filepath.Join(tempDir, account) - os.MkdirAll(accountDir, 0755) + err = os.MkdirAll(accountDir, 0755) + + if err != nil { + t.Fatalf("Failed to create account dir: %v", err) + } // Mock Sources.xml - os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(` + if err := os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(` TUNEIN - `), 0644) - os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(``), 0644) + `), 0644); err != nil { + t.Fatalf("Failed to write Sources.xml: %v", err) + } + + if err := os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(``), 0644); err != nil { + t.Fatalf("Failed to write Presets.xml: %v", err) + } r, _ := setupRouter("http://localhost:8001", ds) @@ -224,25 +267,39 @@ func TestMargeUpdatePreset(t *testing.T) { } } -func TestMargeAddRecent(t *testing.T) { - tempDir, _ := os.MkdirTemp("", "soundcork-test-*") - defer os.RemoveAll(tempDir) +func TestMargeDeviceInfo(t *testing.T) { + tempDir, err := os.MkdirTemp("", "soundcork-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer func() { _ = os.RemoveAll(tempDir) }() ds := datastore.NewDataStore(tempDir) account := "12345" + accountDir := filepath.Join(tempDir, account) - os.MkdirAll(accountDir, 0755) + err = os.MkdirAll(accountDir, 0755) + + if err != nil { + t.Fatalf("Failed to create account dir: %v", err) + } // Mock Sources.xml - os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(` + if err := os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(` TUNEIN - `), 0644) - os.WriteFile(filepath.Join(accountDir, "Recents.xml"), []byte(``), 0644) + `), 0644); err != nil { + t.Fatalf("Failed to write Sources.xml: %v", err) + } + + if err := os.WriteFile(filepath.Join(accountDir, "Recents.xml"), []byte(``), 0644); err != nil { + t.Fatalf("Failed to write Recents.xml: %v", err) + } r, _ := setupRouter("http://localhost:8001", ds) @@ -276,14 +333,23 @@ func TestMargeAddRecent(t *testing.T) { } func TestMargeAddRemoveDevice(t *testing.T) { - tempDir, _ := os.MkdirTemp("", "soundcork-test-*") - defer os.RemoveAll(tempDir) + tempDir, err := os.MkdirTemp("", "soundcork-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer func() { _ = os.RemoveAll(tempDir) }() ds := datastore.NewDataStore(tempDir) account := "12345" + accountDir := filepath.Join(tempDir, account) - os.MkdirAll(accountDir, 0755) + err = os.MkdirAll(accountDir, 0755) + + if err != nil { + t.Fatalf("Failed to create account dir: %v", err) + } r, _ := setupRouter("http://localhost:8001", ds) @@ -313,7 +379,7 @@ func TestMargeAddRemoveDevice(t *testing.T) { t.Fatal(err) } - res.Body.Close() + _ = res.Body.Close() if res.StatusCode != http.StatusOK { t.Errorf("AddDevice: Expected status OK, got %v", res.Status) @@ -332,7 +398,7 @@ func TestMargeAddRemoveDevice(t *testing.T) { t.Fatal(err) } - res.Body.Close() + _ = res.Body.Close() if res.StatusCode != http.StatusOK { t.Errorf("RemoveDevice: Expected status OK, got %v", res.Status) @@ -362,8 +428,12 @@ func TestMargePowerOn(t *testing.T) { } func TestMargeAdvancedFeatures(t *testing.T) { - tempDir, _ := os.MkdirTemp("", "soundcork-test-*") - defer os.RemoveAll(tempDir) + tempDir, err := os.MkdirTemp("", "soundcork-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer func() { _ = os.RemoveAll(tempDir) }() ds := datastore.NewDataStore(tempDir) @@ -431,7 +501,7 @@ func TestMargeAdvancedFeatures(t *testing.T) { t.Fatal(err) } - res.Body.Close() + _ = res.Body.Close() if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) diff --git a/pkg/service/handlers/handlers_media.go b/pkg/service/handlers/handlers_media.go index f778401..3fdd869 100644 --- a/pkg/service/handlers/handlers_media.go +++ b/pkg/service/handlers/handlers_media.go @@ -20,6 +20,7 @@ var bmxServicesJSON []byte //go:embed soundcork/swupdate.xml var swUpdateXML []byte +// HandleRoot returns the root endpoint response. func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) { accept := r.Header.Get("Accept") if !strings.Contains(accept, "text/html") && (strings.Contains(accept, "application/json") || accept == "*/*" || accept == "") { @@ -33,6 +34,7 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(indexHTML) } +// HandleMedia returns a handler for serving media files. func (s *Server) HandleMedia() http.HandlerFunc { subFS, _ := fs.Sub(mediaFS, "soundcork/media") diff --git a/pkg/service/handlers/handlers_proxy.go b/pkg/service/handlers/handlers_proxy.go index c0fc1a4..9fad1f4 100644 --- a/pkg/service/handlers/handlers_proxy.go +++ b/pkg/service/handlers/handlers_proxy.go @@ -9,6 +9,7 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/service/proxy" ) +// HandleProxyRequest handles requests to the logging proxy. func (s *Server) HandleProxyRequest(w http.ResponseWriter, r *http.Request) { targetURLStr := strings.TrimPrefix(r.URL.Path, "/proxy/") if targetURLStr == "" { diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 9897c12..518ecd3 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -7,7 +7,8 @@ import ( "github.com/go-chi/chi/v5" ) -func (s *Server) HandleListDiscoveredDevices(w http.ResponseWriter, r *http.Request) { +// HandleListDiscoveredDevices returns a list of all discovered devices. +func (s *Server) HandleListDiscoveredDevices(w http.ResponseWriter, _ *http.Request) { devices, err := s.ds.ListAllDevices() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -15,29 +16,45 @@ func (s *Server) HandleListDiscoveredDevices(w http.ResponseWriter, r *http.Requ } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(devices) + + if err := json.NewEncoder(w).Encode(devices); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } +// HandleTriggerDiscovery triggers a new device discovery scan. func (s *Server) HandleTriggerDiscovery(w http.ResponseWriter, r *http.Request) { go s.DiscoverDevices(r.Context()) w.WriteHeader(http.StatusAccepted) - w.Write([]byte(`{"status": "Discovery started"}`)) + _, _ = w.Write([]byte(`{"status": "Discovery started"}`)) } -func (s *Server) HandleGetDiscoveryStatus(w http.ResponseWriter, r *http.Request) { +// HandleGetDiscoveryStatus returns the current discovery status. +func (s *Server) HandleGetDiscoveryStatus(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]bool{"discovering": s.discovering}) + + if err := json.NewEncoder(w).Encode(map[string]bool{"discovering": s.discovering}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } -func (s *Server) HandleGetSettings(w http.ResponseWriter, r *http.Request) { +// HandleGetSettings returns the current service settings. +func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ + + if err := json.NewEncoder(w).Encode(map[string]string{ "server_url": s.serverURL, "proxy_url": s.proxyURL, - }) + }); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } +// HandleGetDeviceInfo returns live information for a device. func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) { deviceIP := chi.URLParam(r, "deviceIP") if deviceIP == "" { @@ -52,9 +69,14 @@ func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(info) + + if err := json.NewEncoder(w).Encode(info); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } +// HandleGetMigrationSummary returns a summary of the migration plan for a device. func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Request) { deviceIP := chi.URLParam(r, "deviceIP") if deviceIP == "" { @@ -80,15 +102,24 @@ func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Reques } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(summary) + + if err := json.NewEncoder(w).Encode(summary); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } +// HandleMigrateDevice starts the migration process for a device. func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { deviceIP := chi.URLParam(r, "deviceIP") if deviceIP == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}) + + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } return } @@ -107,21 +138,34 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { if err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}) + + if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } return } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Migration started"}) + + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Migration started"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } +// HandleEnsureRemoteServices ensures that remote services are configured on a device. func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Request) { deviceIP := chi.URLParam(r, "deviceIP") if deviceIP == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}) + + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } return } @@ -129,21 +173,34 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque if err := s.sm.EnsureRemoteServices(deviceIP); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}) + + if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } return } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services ensured"}) + + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services ensured"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } +// HandleBackupConfig creates a backup of the device configuration. func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) { deviceIP := chi.URLParam(r, "deviceIP") if deviceIP == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}) + + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } return } @@ -151,23 +208,37 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) { if err := s.sm.BackupConfig(deviceIP); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}) + + if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } return } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Backup created"}) + + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Backup created"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } -func (s *Server) HandleGetProxySettings(w http.ResponseWriter, r *http.Request) { +// HandleGetProxySettings returns the current proxy settings. +func (s *Server) HandleGetProxySettings(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]bool{ + + if err := json.NewEncoder(w).Encode(map[string]bool{ "redact": s.proxyRedact, "log_body": s.proxyLogBody, - }) + }); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } +// HandleUpdateProxySettings updates the proxy settings. func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Request) { var settings struct { Redact bool `json:"redact"` @@ -182,5 +253,9 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques s.proxyLogBody = settings.LogBody w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Proxy settings updated"}) + + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Proxy settings updated"}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } } diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go index bc16a2a..db7ba87 100644 --- a/pkg/service/handlers/handlers_setup_test.go +++ b/pkg/service/handlers/handlers_setup_test.go @@ -31,8 +31,8 @@ func TestProxySettingsAPI(t *testing.T) { } var settings map[string]bool - if err := json.NewDecoder(res.Body).Decode(&settings); err != nil { - t.Fatalf("GET: Failed to decode response: %v", err) + if decodeErr := json.NewDecoder(res.Body).Decode(&settings); decodeErr != nil { + t.Fatalf("GET: Failed to decode response: %v", decodeErr) } if settings["redact"] != true || settings["log_body"] != false { @@ -44,7 +44,11 @@ func TestProxySettingsAPI(t *testing.T) { "redact": false, "log_body": true, } - body, _ := json.Marshal(update) + + body, err := json.Marshal(update) + if err != nil { + t.Fatalf("Failed to marshal update data: %v", err) + } res, err = http.Post(ts.URL+"/setup/proxy-settings", "application/json", bytes.NewBuffer(body)) if err != nil { diff --git a/pkg/service/handlers/handlers_stats.go b/pkg/service/handlers/handlers_stats.go index 16eba6d..dd54822 100644 --- a/pkg/service/handlers/handlers_stats.go +++ b/pkg/service/handlers/handlers_stats.go @@ -10,6 +10,7 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/models" ) +// HandleUsageStats handles Marge usage stats uploads. func (s *Server) HandleUsageStats(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { @@ -48,6 +49,7 @@ func (s *Server) HandleUsageStats(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +// HandleErrorStats handles Marge error stats uploads. func (s *Server) HandleErrorStats(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { diff --git a/pkg/service/handlers/handlers_stats_test.go b/pkg/service/handlers/handlers_stats_test.go index 793a544..d2d48ff 100644 --- a/pkg/service/handlers/handlers_stats_test.go +++ b/pkg/service/handlers/handlers_stats_test.go @@ -16,7 +16,8 @@ func TestStatsHandlers(t *testing.T) { if err != nil { t.Fatal(err) } - defer os.RemoveAll(tempDir) + + defer func() { _ = os.RemoveAll(tempDir) }() ds := datastore.NewDataStore(tempDir) s := &Server{ds: ds} diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index 8953032..d84a0aa 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -64,5 +64,5 @@ type reverseProxy struct { func (p *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Simplified proxy for testing w.WriteHeader(http.StatusAccepted) // Custom status to identify proxy hit in tests - w.Write([]byte("Proxied to " + p.target.String())) + _, _ = w.Write([]byte("Proxied to " + p.target.String())) } diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 009ebf4..410833f 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -11,6 +11,7 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/service/setup" ) +// Server handles HTTP requests for the SoundTouch service. type Server struct { ds *datastore.DataStore sm *setup.Manager @@ -21,7 +22,8 @@ type Server struct { proxyLogBody bool } -func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact bool, proxyLogBody bool) *Server { +// NewServer creates a new SoundTouch service server. +func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody bool) *Server { return &Server{ ds: ds, sm: sm, @@ -32,6 +34,9 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro } } +// DiscoverDevices starts a background device discovery process. +// +//nolint:contextcheck func (s *Server) DiscoverDevices(ctx context.Context) { s.discovering = true @@ -55,63 +60,64 @@ func (s *Server) DiscoverDevices(ctx context.Context) { } for _, d := range devices { - log.Printf("Discovered Bose device: %s at %s (Serial: %s)", d.Name, d.Host, d.SerialNo) - - // 1. Check if we already have this device by serial number (best identifier) - var existingID string // The directory name used for this device - - allDevices, _ := s.ds.ListAllDevices() - for _, known := range allDevices { - if d.SerialNo != "" && (known.DeviceID == d.SerialNo || known.DeviceSerialNumber == d.SerialNo) { - existingID = known.DeviceID - if existingID == "" { - existingID = known.IPAddress - } - - break - } - } - - // Use SerialNo if available, otherwise fallback to IP for the datastore directory name - deviceID := d.SerialNo - if deviceID == "" { - // If serial is missing from discovery, try to fetch it from :8090/info - log.Printf("Serial number missing for %s at %s, attempting live info fetch...", d.Name, d.Host) - - liveInfo, err := s.sm.GetLiveDeviceInfo(d.Host) - if err == nil && liveInfo.SerialNumber != "" { - d.SerialNo = liveInfo.SerialNumber - log.Printf("Successfully retrieved serial number %s for %s via live info", d.SerialNo, d.Host) - } - } - - deviceID = d.SerialNo - if deviceID == "" { - deviceID = d.Host - } - - // 2. If we found it by serial but it was stored under an IP-based directory, - // we should ideally migrate it, but for now, we'll just ensure the Serial one is used. - // If the IP changed for a known serial, SaveDeviceInfo will overwrite the old IP info - // if deviceID == existingBySerial.DeviceID. - - info := &models.ServiceDeviceInfo{ - DeviceID: d.SerialNo, - Name: d.Name, - IPAddress: d.Host, - DeviceSerialNumber: d.SerialNo, - ProductCode: d.ModelID, - FirmwareVersion: "0.0.0", // Unknown from discovery - } - - // If we had an IP-based entry and now have a Serial, clean up the IP-based entry - if d.SerialNo != "" && existingID != "" && existingID != d.SerialNo { - log.Printf("Device %s previously known as %s, migrating to serial-based ID %s", d.Name, existingID, d.SerialNo) - _ = s.ds.RemoveDevice("default", existingID) - } - - if err := s.ds.SaveDeviceInfo("default", deviceID, info); err != nil { - log.Printf("Failed to save device info: %v", err) - } + s.handleDiscoveredDevice(*d) } } + +func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { + log.Printf("Discovered Bose device: %s at %s (Serial: %s)", d.Name, d.Host, d.SerialNo) + + // 1. Check if we already have this device by serial number (best identifier) + existingID := s.findExistingDeviceID(d) + + // Use SerialNo if available, otherwise fallback to IP for the datastore directory name + if d.SerialNo == "" { + // If serial is missing from discovery, try to fetch it from :8090/info + log.Printf("Serial number missing for %s at %s, attempting live info fetch...", d.Name, d.Host) + + liveInfo, err := s.sm.GetLiveDeviceInfo(d.Host) + if err == nil && liveInfo.SerialNumber != "" { + d.SerialNo = liveInfo.SerialNumber + log.Printf("Successfully retrieved serial number %s for %s via live info", d.SerialNo, d.Host) + } + } + + deviceID := d.SerialNo + if deviceID == "" { + deviceID = d.Host + } + + info := &models.ServiceDeviceInfo{ + DeviceID: d.SerialNo, + Name: d.Name, + IPAddress: d.Host, + DeviceSerialNumber: d.SerialNo, + ProductCode: d.ModelID, + FirmwareVersion: "0.0.0", // Unknown from discovery + } + + // If we had an IP-based entry and now have a Serial, clean up the IP-based entry + if d.SerialNo != "" && existingID != "" && existingID != d.SerialNo { + log.Printf("Device %s previously known as %s, migrating to serial-based ID %s", d.Name, existingID, d.SerialNo) + _ = s.ds.RemoveDevice("default", existingID) + } + + if err := s.ds.SaveDeviceInfo("default", deviceID, info); err != nil { + log.Printf("Failed to save device info: %v", err) + } +} + +func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string { + allDevices, _ := s.ds.ListAllDevices() + for _, known := range allDevices { + if d.SerialNo != "" && (known.DeviceID == d.SerialNo || known.DeviceSerialNumber == d.SerialNo) { + if known.DeviceID != "" { + return known.DeviceID + } + + return known.IPAddress + } + } + + return "" +} diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index b3991b4..4a2d70d 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -115,7 +115,9 @@ func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) { } res := `` - for _, p := range presets { + + for i := range presets { + p := &presets[i] res += fmt.Sprintf(``, p.ID) res += fmt.Sprintf(`%s`, p.ContainerArt) res += fmt.Sprintf(`%s`, p.Type) @@ -161,7 +163,9 @@ func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) { res := `` - for _, r := range recents { + for i := range recents { + r := &recents[i] + lastPlayed := "" if sec, err := strconv.ParseInt(r.UtcTime, 10, 64); err == nil { lastPlayed = time.Unix(sec, 0).Format(time.RFC3339) @@ -174,6 +178,7 @@ func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) { res += fmt.Sprintf(`%s`, r.Location) res += fmt.Sprintf(`%s`, r.Name) + // Content Item Source found := false for _, s := range sources { @@ -186,6 +191,7 @@ func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) { } if !found { + // This might happen if source is not found } res += fmt.Sprintf(`%s`, DateStr) @@ -217,37 +223,37 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { lastDeviceID := "" for _, entry := range entries { - if entry.IsDir() { - deviceID := entry.Name() - lastDeviceID = deviceID - - info, err := ds.GetDeviceInfo(account, deviceID) - if err != nil { - continue - } - - res += fmt.Sprintf(``, deviceID) - res += fmt.Sprintf(`%s%s`, - info.ProductCode, info.ProductCode, info.ProductSerialNumber) - res += fmt.Sprintf(`%s`, DateStr) - res += fmt.Sprintf(`%s`, info.FirmwareVersion) - res += fmt.Sprintf(`%s`, info.IPAddress) - res += fmt.Sprintf(`%s`, info.Name) - - presets, _ := PresetsToXML(ds, account) - if len(presets) > len(xml.Header) { - res += string(presets[len(xml.Header):]) // strip header - } - - recents, _ := RecentsToXML(ds, account) - if len(recents) > len(xml.Header) { - res += string(recents[len(xml.Header):]) // strip header - } - - res += fmt.Sprintf(`%s`, info.DeviceSerialNumber) - res += fmt.Sprintf(`%s`, DateStr) - res += `` + if !entry.IsDir() { + continue } + + deviceID := entry.Name() + lastDeviceID = deviceID + + info, err := ds.GetDeviceInfo(account, deviceID) + if err != nil { + continue + } + + res += fmt.Sprintf(``, deviceID) + res += fmt.Sprintf(`%s%s`, + info.ProductCode, info.ProductCode, info.ProductSerialNumber) + res += fmt.Sprintf(`%s`, DateStr) + res += fmt.Sprintf(`%s`, info.FirmwareVersion) + res += fmt.Sprintf(`%s`, info.IPAddress) + res += fmt.Sprintf(`%s`, info.Name) + + presets, _ := PresetsToXML(ds, account) + if len(presets) > len(xml.Header) { + res += string(presets[len(xml.Header):]) // strip header + } + + recents, _ := RecentsToXML(ds, account) + if len(recents) > len(xml.Header) { + res += string(recents[len(xml.Header):]) // strip header + } + + res += `` } res += `globalen` @@ -269,7 +275,7 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { return []byte(res), nil } -func UpdatePreset(ds *datastore.DataStore, account string, device string, presetNumber int, sourceXML []byte) ([]byte, error) { +func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber int, sourceXML []byte) ([]byte, error) { sources, err := ds.GetConfiguredSources(account) if err != nil { return nil, err @@ -345,7 +351,7 @@ func UpdatePreset(ds *datastore.DataStore, account string, device string, preset return append([]byte(xml.Header), []byte(res)...), nil } -func AddRecent(ds *datastore.DataStore, account string, device string, sourceXML []byte) ([]byte, error) { +func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte) ([]byte, error) { sources, err := ds.GetConfiguredSources(account) if err != nil { return nil, err @@ -367,39 +373,23 @@ func AddRecent(ds *datastore.DataStore, account string, device string, sourceXML return nil, err } - var matchingSrc *models.ConfiguredSource - - for _, s := range sources { - if s.ID == newRecentElem.SourceID { - matchingSrc = &s - break - } - } - + matchingSrc := findMatchingSource(sources, newRecentElem.SourceID) if matchingSrc == nil { return nil, fmt.Errorf("invalid account/source") } - utcTime := time.Now().Unix() - - if newRecentElem.LastPlayedAt != "" { - if t, err := time.Parse(time.RFC3339, newRecentElem.LastPlayedAt); err == nil { - utcTime = t.Unix() - } - } + utcTime := parseLastPlayedAt(newRecentElem.LastPlayedAt) // Find existing var recentObj *models.ServiceRecent createdOn := DateStr - for i, r := range recents { + for i := range recents { + r := &recents[i] if r.Source == matchingSrc.SourceKeyType && r.Location == newRecentElem.Location && r.SourceAccount == matchingSrc.SourceKeyAccount { recents[i].UtcTime = strconv.FormatInt(utcTime, 10) recentObj = &recents[i] - // Moving to front means we need to handle its original createdOn - // In bose emulation, we often use fixed dates, but let's try to be consistent - // If we had a real createdOn, we'd use it here. // Move to front recents = append([]models.ServiceRecent{*recentObj}, append(recents[:i], recents[i+1:]...)...) @@ -409,27 +399,7 @@ func AddRecent(ds *datastore.DataStore, account string, device string, sourceXML } if recentObj == nil { - maxID := 0 - for _, r := range recents { - if id, err := strconv.Atoi(r.ID); err == nil && id > maxID { - maxID = id - } - } - - recentObj = &models.ServiceRecent{ - ServiceContentItem: models.ServiceContentItem{ - ID: strconv.Itoa(maxID + 1), - Name: newRecentElem.Name, - Source: matchingSrc.SourceKeyType, - Type: newRecentElem.ContentItemType, - Location: newRecentElem.Location, - SourceAccount: matchingSrc.SourceKeyAccount, - SourceID: newRecentElem.SourceID, - IsPresetable: "true", - }, - DeviceID: device, - UtcTime: strconv.FormatInt(utcTime, 10), - } + recentObj = createNewRecent(recents, newRecentElem.Name, matchingSrc, newRecentElem.ContentItemType, newRecentElem.Location, device, utcTime) createdOn = time.Now().Format(time.RFC3339) recents = append([]models.ServiceRecent{*recentObj}, recents...) @@ -442,6 +412,56 @@ func AddRecent(ds *datastore.DataStore, account string, device string, sourceXML return nil, err } + return formatRecentResponse(recentObj, matchingSrc, createdOn, utcTime), nil +} + +func findMatchingSource(sources []models.ConfiguredSource, sourceID string) *models.ConfiguredSource { + for _, s := range sources { + if s.ID == sourceID { + return &s + } + } + + return nil +} + +func parseLastPlayedAt(lastPlayedAt string) int64 { + utcTime := time.Now().Unix() + + if lastPlayedAt != "" { + if t, err := time.Parse(time.RFC3339, lastPlayedAt); err == nil { + utcTime = t.Unix() + } + } + + return utcTime +} + +func createNewRecent(recents []models.ServiceRecent, name string, matchingSrc *models.ConfiguredSource, contentItemType, location, device string, utcTime int64) *models.ServiceRecent { + maxID := 0 + for j := range recents { + if id, err := strconv.Atoi(recents[j].ID); err == nil && id > maxID { + maxID = id + } + } + + return &models.ServiceRecent{ + ServiceContentItem: models.ServiceContentItem{ + ID: strconv.Itoa(maxID + 1), + Name: name, + Source: matchingSrc.SourceKeyType, + Type: contentItemType, + Location: location, + SourceAccount: matchingSrc.SourceKeyAccount, + SourceID: matchingSrc.ID, + IsPresetable: "true", + }, + DeviceID: device, + UtcTime: strconv.FormatInt(utcTime, 10), + } +} + +func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.ConfiguredSource, createdOn string, utcTime int64) []byte { lastPlayed := time.Unix(utcTime, 0).Format(time.RFC3339) res := fmt.Sprintf(``, recentObj.ID) res += fmt.Sprintf(`%s`, recentObj.Type) @@ -453,7 +473,7 @@ func AddRecent(ds *datastore.DataStore, account string, device string, sourceXML res += fmt.Sprintf(`%s`, DateStr) res += `` - return append([]byte(xml.Header), []byte(res)...), nil + return append([]byte(xml.Header), []byte(res)...) } func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte) ([]byte, error) { @@ -486,6 +506,6 @@ func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byt return append([]byte(xml.Header), []byte(res)...), nil } -func RemoveDeviceFromAccount(ds *datastore.DataStore, account string, device string) error { +func RemoveDeviceFromAccount(ds *datastore.DataStore, account, device string) error { return ds.RemoveDevice(account, device) } diff --git a/pkg/service/marge/marge_test.go b/pkg/service/marge/marge_test.go index 2c94833..7b5c342 100644 --- a/pkg/service/marge/marge_test.go +++ b/pkg/service/marge/marge_test.go @@ -11,8 +11,12 @@ import ( ) func TestMargeXML(t *testing.T) { - tempDir, _ := os.MkdirTemp("", "marge-test-*") - defer os.RemoveAll(tempDir) + tempDir, err := os.MkdirTemp("", "marge-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer func() { _ = os.RemoveAll(tempDir) }() ds := datastore.NewDataStore(tempDir) account := "123" @@ -61,8 +65,12 @@ func TestMargeXML(t *testing.T) { } func TestAddRecent_TimestampPreservation(t *testing.T) { - tempDir, _ := os.MkdirTemp("", "marge-timestamp-test-*") - defer os.RemoveAll(tempDir) + tempDir, err := os.MkdirTemp("", "marge-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer func() { _ = os.RemoveAll(tempDir) }() ds := datastore.NewDataStore(tempDir) account := "test-acc" @@ -91,7 +99,7 @@ func TestAddRecent_TimestampPreservation(t *testing.T) { station `) - _, err := AddRecent(ds, account, device, sourceXML) + _, err = AddRecent(ds, account, device, sourceXML) if err != nil { t.Fatalf("AddRecent failed: %v", err) } diff --git a/pkg/service/proxy/proxy.go b/pkg/service/proxy/proxy.go index c788a03..381f65c 100644 --- a/pkg/service/proxy/proxy.go +++ b/pkg/service/proxy/proxy.go @@ -1,3 +1,4 @@ +// Package proxy provides a logging reverse proxy used for speaker traffic debugging. package proxy import ( @@ -25,7 +26,8 @@ type LoggingProxy struct { MaxBodySize int64 } -func NewLoggingProxy(targetURL string, redact bool) *LoggingProxy { +// NewLoggingProxy creates a lightweight logger for HTTP requests/responses. +func NewLoggingProxy(_ string, redact bool) *LoggingProxy { // targetURL logic should be handled by the caller or we can parse it here return &LoggingProxy{ Redact: redact, @@ -34,6 +36,7 @@ func NewLoggingProxy(targetURL string, redact bool) *LoggingProxy { } } +// LogRequest prints an abbreviated request with optional header/body redaction. func (lp *LoggingProxy) LogRequest(r *http.Request) { headers := formatHeaders(r.Header, lp.Redact) @@ -57,6 +60,7 @@ func (lp *LoggingProxy) LogRequest(r *http.Request) { log.Printf("[PROXY_REQ] %s %s\n Headers:\n%s\n Body: %s", r.Method, r.URL.String(), headers, bodyStr) } +// LogResponse prints an abbreviated response with optional header/body redaction. func (lp *LoggingProxy) LogResponse(r *http.Response) { headers := formatHeaders(r.Header, lp.Redact) diff --git a/pkg/service/proxy/proxy_test.go b/pkg/service/proxy/proxy_test.go index b8b92c5..11c6620 100644 --- a/pkg/service/proxy/proxy_test.go +++ b/pkg/service/proxy/proxy_test.go @@ -57,9 +57,11 @@ func TestShouldLogBody(t *testing.T) { } func TestLoggingProxy_LogRequest(t *testing.T) { - os.Setenv("LOG_PROXY_BODY", "true") + if err := os.Setenv("LOG_PROXY_BODY", "true"); err != nil { + t.Fatalf("Failed to set LOG_PROXY_BODY: %v", err) + } - defer os.Unsetenv("LOG_PROXY_BODY") + defer func() { _ = os.Unsetenv("LOG_PROXY_BODY") }() lp := NewLoggingProxy("http://example.com", true) diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index 876c348..ffd5674 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -1,3 +1,4 @@ +// Package setup contains speaker migration and configuration helpers. package setup import ( @@ -11,6 +12,7 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/service/ssh" ) +// SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device. const SoundTouchSdkPrivateCfgPath = "/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml" // PrivateCfg represents the SoundTouchSdkPrivateCfg XML structure. @@ -82,7 +84,8 @@ func (m *Manager) GetLiveDeviceInfo(deviceIP string) (*DeviceInfoXML, error) { if err != nil { return nil, fmt.Errorf("failed to fetch info from %s: %w", infoURL, err) } - defer resp.Body.Close() + + defer func() { _ = resp.Body.Close() }() var infoXML DeviceInfoXML if err := xml.NewDecoder(resp.Body).Decode(&infoXML); err != nil { @@ -107,13 +110,11 @@ func (m *Manager) GetLiveDeviceInfo(deviceIP string) (*DeviceInfoXML, error) { } // GetMigrationSummary returns a summary of the current and planned state of the speaker. -func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyURL string, options map[string]string) (*MigrationSummary, error) { +func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, options map[string]string) (*MigrationSummary, error) { if targetURL == "" { targetURL = m.ServerURL } - client := ssh.NewClient(deviceIP) - summary := &MigrationSummary{ SSHSuccess: false, } @@ -123,14 +124,16 @@ func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyUR devices, err := m.DataStore.ListAllDevices() if err == nil { for _, d := range devices { - if d.IPAddress == deviceIP { - summary.DeviceName = d.Name - summary.DeviceModel = d.ProductCode - summary.DeviceSerial = d.DeviceSerialNumber - summary.FirmwareVersion = d.FirmwareVersion - - break + if d.IPAddress != deviceIP { + continue } + + summary.DeviceName = d.Name + summary.DeviceModel = d.ProductCode + summary.DeviceSerial = d.DeviceSerialNumber + summary.FirmwareVersion = d.FirmwareVersion + + break } } else { log.Printf("Warning: failed to list devices from datastore: %v", err) @@ -174,10 +177,10 @@ func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyUR var currentConfig string path := SoundTouchSdkPrivateCfgPath - client = ssh.NewClient(deviceIP) + client := ssh.NewClient(deviceIP) // Check if .original exists - if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", path)); err == nil { + if _, checkErr := client.Run(fmt.Sprintf("[ -f %s.original ]", path)); checkErr == nil { originalConfig, _ := client.Run(fmt.Sprintf("cat %s.original", path)) if originalConfig != "" { summary.OriginalConfig = originalConfig @@ -240,8 +243,8 @@ func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyUR if config == "" && fileInfo != "" { fmt.Printf("Cat returned empty for %s, trying base64\n", path) - b64Config, err := client.Run(fmt.Sprintf("base64 %s", path)) - if err == nil && b64Config != "" { + b64Config, configErr := client.Run(fmt.Sprintf("base64 %s", path)) + if configErr == nil && b64Config != "" { fmt.Printf("Base64 output for %s (length %d)\n", path, len(b64Config)) } } @@ -290,7 +293,7 @@ func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyUR } // MigrateSpeaker configures the speaker at the given IP to use this soundcork service. -func (m *Manager) MigrateSpeaker(deviceIP string, targetURL string, proxyURL string, options map[string]string) error { +func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string) error { if targetURL == "" { targetURL = m.ServerURL } @@ -400,12 +403,13 @@ func (m *Manager) BackupConfig(deviceIP string) error { } // Try to copy on the device first (more reliable), ensuring filesystem is writable - if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err == nil { + output, cpErr := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)) + if cpErr == nil { return nil - } else { - fmt.Printf("Direct cp failed: %v (output: %s), falling back to cat+upload\n", err, output) } + fmt.Printf("Direct cp failed: %v (output: %s), falling back to cat+upload\n", cpErr, output) + // Fallback to cat + upload config, err := client.Run(fmt.Sprintf("cat %s", remotePath)) if err != nil || config == "" { diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index affaa66..83840be 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -15,7 +15,7 @@ func TestGetLiveDeviceInfo(t *testing.T) { } w.Header().Set("Content-Type", "application/xml") - fmt.Fprint(w, ` + _, _ = fmt.Fprint(w, ` Test Speaker SoundTouch 20 @@ -81,7 +81,7 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) { // Setup a mock server for live info server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") - fmt.Fprint(w, `Test`) + _, _ = fmt.Fprint(w, `Test`) })) defer server.Close() diff --git a/pkg/service/ssh/ssh.go b/pkg/service/ssh/ssh.go index c15a428..8b5a882 100644 --- a/pkg/service/ssh/ssh.go +++ b/pkg/service/ssh/ssh.go @@ -1,3 +1,4 @@ +// Package ssh provides simple SSH operations used during device setup and migration. package ssh import ( @@ -56,7 +57,6 @@ func (c *Client) getConfig() *ssh.ClientConfig { ssh.KeyAlgoRSASHA256, ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSA, - ssh.KeyAlgoDSA, ssh.KeyAlgoECDSA256, ssh.KeyAlgoECDSA384, ssh.KeyAlgoECDSA521, @@ -73,13 +73,15 @@ func (c *Client) Run(command string) (string, error) { if err != nil { return "", fmt.Errorf("failed to dial: %w", err) } - defer client.Close() + + defer func() { _ = client.Close() }() session, err := client.NewSession() if err != nil { return "", fmt.Errorf("failed to create session: %w", err) } - defer session.Close() + + defer func() { _ = session.Close() }() output, err := session.CombinedOutput(command) @@ -96,13 +98,15 @@ func (c *Client) UploadContent(content []byte, remotePath string) error { if err != nil { return fmt.Errorf("failed to dial: %w", err) } - defer client.Close() + + defer func() { _ = client.Close() }() session, err := client.NewSession() if err != nil { return fmt.Errorf("failed to create session: %w", err) } - defer session.Close() + + defer func() { _ = session.Close() }() // Use a pipe to write content to the remote command's stdin stdin, err := session.StdinPipe() @@ -120,13 +124,14 @@ func (c *Client) UploadContent(content []byte, remotePath string) error { cmd := fmt.Sprintf("cat > %s", remotePath) // Start the command - if err := session.Start(cmd); err != nil { - return fmt.Errorf("failed to start upload command: %w", err) + startErr := session.Start(cmd) + if startErr != nil { + return fmt.Errorf("failed to start upload command: %w", startErr) } // Write content and close stdin _, err = stdin.Write(content) - stdin.Close() + _ = stdin.Close() if err != nil { return fmt.Errorf("failed to write content to stdin: %w", err) @@ -134,7 +139,8 @@ func (c *Client) UploadContent(content []byte, remotePath string) error { // Read stderr in case of failure stderrBuf := new(strings.Builder) - go io.Copy(stderrBuf, stderr) + + go func() { _, _ = io.Copy(stderrBuf, stderr) }() // Wait for the command to finish if err := session.Wait(); err != nil {