From b2ccd048448e3f22b47812bb0b18344fedc5c69b Mon Sep 17 00:00:00 2001 From: soldosluka857 Date: Sat, 30 May 2026 04:34:12 +0000 Subject: [PATCH] fix(dlna): always return non-nil device slice so API serializes [] not null The DLNA device list endpoint could return {"devices":null} in two cases: the SSDP-failure path returned a nil slice, and the cached path copied an empty cache via append([]DLNADevice(nil), cache...) which also yields nil. The web UI reads res.devices.length, so a null devices field crashed the DLNA page to a white screen. Return non-nil slices in both paths and guard the frontend with a null coalesce. Adds a regression test. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/service/dlna.go | 8 +++++--- internal/service/dlna_test.go | 37 +++++++++++++++++++++++++++++++++++ web/src/api/dlna.ts | 4 ++-- 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 internal/service/dlna_test.go diff --git a/internal/service/dlna.go b/internal/service/dlna.go index a35cfa6..479120a 100644 --- a/internal/service/dlna.go +++ b/internal/service/dlna.go @@ -114,7 +114,8 @@ func (d *DLNAService) ssdpDiscover(ctx context.Context, timeout time.Duration) ( func (d *DLNAService) Discover(ctx context.Context, force bool) ([]DLNADevice, error) { d.mu.Lock() if !force && time.Since(d.cachedAt) < 30*time.Second && d.cache != nil { - out := append([]DLNADevice(nil), d.cache...) + // Copy into a non-nil slice so an empty cache serializes as [] not null. + out := append(make([]DLNADevice, 0, len(d.cache)), d.cache...) d.mu.Unlock() return out, nil } @@ -123,9 +124,10 @@ func (d *DLNAService) Discover(ctx context.Context, force bool) ([]DLNADevice, e locations, err := d.ssdpDiscover(ctx, 3*time.Second) if err != nil { // SSDP often fails on container networks; treat as "no devices" - // rather than 500 the API. + // rather than 500 the API. Return an empty (non-nil) slice so the + // JSON response is [] not null. d.log.Debug("ssdp discover failed", zap.Error(err)) - return nil, nil + return []DLNADevice{}, nil } devices := make([]DLNADevice, 0, len(locations)) for _, loc := range locations { diff --git a/internal/service/dlna_test.go b/internal/service/dlna_test.go new file mode 100644 index 0000000..4337c62 --- /dev/null +++ b/internal/service/dlna_test.go @@ -0,0 +1,37 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "go.uber.org/zap" +) + +// TestDLNADiscoverCachedEmptyReturnsNonNil guards against the bug where an +// empty device cache was copied via append([]DLNADevice(nil), cache...), +// which yields a nil slice and serializes to JSON null. The web UI reads +// res.devices.length, so a null devices field crashed the DLNA page. +func TestDLNADiscoverCachedEmptyReturnsNonNil(t *testing.T) { + d := NewDLNAService(zap.NewNop()) + // Prime the cache with an empty (but non-nil) result, fresh enough to be served. + d.cache = []DLNADevice{} + d.cachedAt = time.Now() + + out, err := d.Discover(context.Background(), false) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + if out == nil { + t.Fatal("Discover returned nil slice for empty cache; want non-nil so JSON is [] not null") + } + + b, err := json.Marshal(map[string]any{"devices": out}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if got := string(b); got != `{"devices":[]}` { + t.Fatalf("devices serialized as %s; want {\"devices\":[]}", got) + } +} diff --git a/web/src/api/dlna.ts b/web/src/api/dlna.ts index 72ba023..b1134bd 100644 --- a/web/src/api/dlna.ts +++ b/web/src/api/dlna.ts @@ -13,8 +13,8 @@ export interface DLNADevice { export const dlnaAPI = { list: (force = false) => api - .get<{ devices: DLNADevice[] }>('/dlna/devices', { params: { force: force ? 'true' : '' } }) - .then((r) => r.data.devices), + .get<{ devices: DLNADevice[] | null }>('/dlna/devices', { params: { force: force ? 'true' : '' } }) + .then((r) => r.data.devices ?? []), cast: (controlURL: string, mediaURL: string) => api