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