Skip to content

Commit d514e4e

Browse files
authored
feat(api): add audio source listing endpoints (#2913)
* feat(api): add audio source listing handlers and tests Add ListAudioSources (GET /system/audio/sources) for all registered sources and ListStreamSources (GET /streams/sources) for stream-type sources only. Both read from the engine's SourceRegistry. Refs: #2911 * feat(api): register audio source listing routes Wire ListAudioSources into the system audio group (protected) and ListStreamSources into the streams group (public, matching audio-level SSE access pattern). Refs: #2911 * docs(api): document audio source listing endpoints Add /system/audio/sources and /streams/sources to the API reference with response format and field value documentation. Refs: #2911 * refactor(api): extract shared helper for source listing handlers Unify ListAudioSources and ListStreamSources into a shared listSources helper with an optional filter predicate. Also fix isStreamSourceType to accept audiocore.SourceType directly (avoiding string roundtrip), extract toAudioSourceInfo constructor, and preallocate the response slice. * fix(api): anonymize source names for unauthenticated clients ListStreamSources is public, so apply the same anonymization as StreamAudioLevel: replace raw DisplayName with anonymized values when the caller is not authenticated.
1 parent 5843bee commit d514e4e

5 files changed

Lines changed: 326 additions & 4 deletions

File tree

internal/api/v2/README.md

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,11 +228,12 @@ The `GET /settings/dashboard` endpoint is intentionally public so that unauthent
228228
| GET | `/soundlevels/stream` | `StreamSoundLevels` | ❌⚡ | Real-time audio level stream |
229229
| GET | `/sse/status` | `GetSSEStatus` || SSE connection status |
230230

231-
### Audio Level SSE (`audio_level.go`)
231+
### Audio Level SSE (`audio_level.go`) and Stream Sources (`audio_sources.go`)
232232

233-
| Method | Route | Handler | Auth | Description |
234-
| ------ | ---------------------- | ------------------ | ---- | ------------------------- |
235-
| GET | `/streams/audio-level` | `StreamAudioLevel` || Real-time audio level SSE |
233+
| Method | Route | Handler | Auth | Description |
234+
| ------ | ---------------------- | -------------------- | ---- | ---------------------------------------- |
235+
| GET | `/streams/audio-level` | `StreamAudioLevel` || Real-time audio level SSE |
236+
| GET | `/streams/sources` | `ListStreamSources` || Active stream sources (RTSP, HTTP, etc.) |
236237

237238
**Features:**
238239

@@ -258,6 +259,23 @@ The `GET /settings/dashboard` endpoint is intentionally public so that unauthent
258259
}
259260
```
260261

262+
**Stream Sources Response (`/streams/sources` and `/system/audio/sources`):**
263+
264+
```json
265+
{
266+
"sources": [
267+
{
268+
"id": "rtsp_ce4e5692",
269+
"name": "RPI",
270+
"type": "rtsp",
271+
"state": "running"
272+
}
273+
]
274+
}
275+
```
276+
277+
Returns an empty `sources` array when no sources are configured. The `type` field is one of: `rtsp`, `http`, `hls`, `rtmp`, `udp`, `audio_card`, `file`. The `state` field is one of: `inactive`, `starting`, `running`, `error`, `stopped`. The `/streams/sources` endpoint returns only stream types (excludes `audio_card` and `file`).
278+
261279
### HLS Streaming (`audio_hls.go`)
262280

263281
| Method | Route | Handler | Auth | Description |
@@ -387,6 +405,7 @@ HLS playlist and segment routes use token-based authentication instead of standa
387405
| GET | `/system/audio/devices` | `GetAudioDevices` || Available audio devices |
388406
| GET | `/system/audio/active` | `GetActiveAudioDevice` || Active audio device |
389407
| GET | `/system/audio/equalizer/config` | `GetEqualizerConfig` || Audio equalizer filter configuration |
408+
| GET | `/system/audio/sources` | `ListAudioSources` || Active audio sources (all types) |
390409
| GET | `/system/network-interfaces` | `GetNetworkInterfaces` || IPv4 network interfaces for binding |
391410

392411
### Events (`events.go`, `events_aggregation.go`)

internal/api/v2/audio_level.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ func (c *Controller) initAudioLevelRoutes() {
185185
// The per-IP connection limit (audioLevelMaxConnectionsPerIP) still applies
186186
// Authentication is checked within the handler to control data anonymization
187187
c.Group.GET("/streams/audio-level", c.StreamAudioLevel)
188+
189+
// Stream sources listing - public, returns active stream sources
190+
c.Group.GET("/streams/sources", c.ListStreamSources)
188191
}
189192

190193
// StreamAudioLevel handles SSE connections for real-time audio level streaming

internal/api/v2/audio_sources.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// audio_sources.go - Handlers for audio source listing endpoints.
2+
package api
3+
4+
import (
5+
"net/http"
6+
7+
"github.com/labstack/echo/v4"
8+
"github.com/tphakala/birdnet-go/internal/audiocore"
9+
"github.com/tphakala/birdnet-go/internal/logger"
10+
)
11+
12+
// AudioSourceInfo represents a single audio source in API responses.
13+
type AudioSourceInfo struct {
14+
ID string `json:"id"`
15+
Name string `json:"name"`
16+
Type string `json:"type"`
17+
State string `json:"state"`
18+
}
19+
20+
// AudioSourceListResponse is the response for the audio sources listing endpoints.
21+
type AudioSourceListResponse struct {
22+
Sources []AudioSourceInfo `json:"sources"`
23+
}
24+
25+
// toAudioSourceInfo converts an audiocore.AudioSource to the API response type.
26+
func toAudioSourceInfo(src *audiocore.AudioSource) AudioSourceInfo {
27+
return AudioSourceInfo{
28+
ID: src.ID,
29+
Name: src.DisplayName,
30+
Type: string(src.Type),
31+
State: src.State.String(),
32+
}
33+
}
34+
35+
// isStreamSourceType returns true for source types that represent network streams.
36+
func isStreamSourceType(t audiocore.SourceType) bool {
37+
switch t {
38+
case audiocore.SourceTypeRTSP,
39+
audiocore.SourceTypeHTTP,
40+
audiocore.SourceTypeHLS,
41+
audiocore.SourceTypeRTMP,
42+
audiocore.SourceTypeUDP:
43+
return true
44+
default:
45+
return false
46+
}
47+
}
48+
49+
// listSources is the shared implementation for source listing endpoints.
50+
// When filter is nil, all sources are included. When anonymize is true,
51+
// source names are replaced with anonymized values for unauthenticated
52+
// clients, matching the behavior of the audio-level SSE stream.
53+
func (c *Controller) listSources(ctx echo.Context, label string, filter func(audiocore.SourceType) bool, anonymize bool) error {
54+
c.logInfoIfEnabled("Listing "+label,
55+
logger.String("path", ctx.Request().URL.Path),
56+
logger.String("ip", ctx.RealIP()),
57+
)
58+
59+
resp := AudioSourceListResponse{Sources: []AudioSourceInfo{}}
60+
61+
if c.engine == nil {
62+
return ctx.JSON(http.StatusOK, resp)
63+
}
64+
65+
registry := c.engine.Registry()
66+
if registry == nil {
67+
return ctx.JSON(http.StatusOK, resp)
68+
}
69+
70+
sources := registry.List()
71+
isAuthenticated := !anonymize || c.isClientAuthenticated(ctx)
72+
resp.Sources = make([]AudioSourceInfo, 0, len(sources))
73+
for _, src := range sources {
74+
if filter != nil && !filter(src.Type) {
75+
continue
76+
}
77+
info := toAudioSourceInfo(src)
78+
if !isAuthenticated {
79+
info.Name = c.getAnonymizedSourceName(src)
80+
}
81+
resp.Sources = append(resp.Sources, info)
82+
}
83+
84+
c.logInfoIfEnabled(label+" listed",
85+
logger.Int("count", len(resp.Sources)),
86+
logger.String("path", ctx.Request().URL.Path),
87+
logger.String("ip", ctx.RealIP()),
88+
)
89+
90+
return ctx.JSON(http.StatusOK, resp)
91+
}
92+
93+
// ListAudioSources handles GET /api/v2/system/audio/sources.
94+
// Returns all active audio sources from the engine registry (sound cards + streams).
95+
func (c *Controller) ListAudioSources(ctx echo.Context) error {
96+
return c.listSources(ctx, "audio sources", nil, false)
97+
}
98+
99+
// ListStreamSources handles GET /api/v2/streams/sources.
100+
// Returns only stream-type audio sources (RTSP, HTTP, HLS, RTMP, UDP).
101+
// Source names are anonymized for unauthenticated clients.
102+
func (c *Controller) ListStreamSources(ctx echo.Context) error {
103+
return c.listSources(ctx, "stream sources", isStreamSourceType, true)
104+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// audio_sources_test.go - Tests for audio source listing endpoints.
2+
package api
3+
4+
import (
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/labstack/echo/v4"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"github.com/tphakala/birdnet-go/internal/audiocore"
14+
"github.com/tphakala/birdnet-go/internal/audiocore/engine"
15+
"github.com/tphakala/birdnet-go/internal/conf"
16+
)
17+
18+
// setupAudioSourcesTestEnv creates a Controller with an AudioEngine whose
19+
// registry contains the given sources. Sources are registered directly in the
20+
// registry to avoid requiring real audio hardware or network streams.
21+
// The engine is stopped automatically when the test completes.
22+
func setupAudioSourcesTestEnv(t *testing.T, sources []*audiocore.SourceConfig) (*echo.Echo, *Controller) {
23+
t.Helper()
24+
25+
ctx := t.Context()
26+
log := audiocore.GetLogger()
27+
eng := engine.New(ctx, &engine.Config{Logger: log}, nil)
28+
t.Cleanup(eng.Stop)
29+
30+
// Register sources directly in the registry instead of using
31+
// engine.AddSource, which tries to start real hardware capture
32+
// for audio cards and FFmpeg for streams.
33+
registry := eng.Registry()
34+
for _, cfg := range sources {
35+
_, err := registry.Register(cfg)
36+
require.NoError(t, err)
37+
}
38+
39+
e := echo.New()
40+
controller := &Controller{
41+
Echo: e,
42+
Group: e.Group("/api/v2"),
43+
Settings: &conf.Settings{},
44+
engine: eng,
45+
}
46+
return e, controller
47+
}
48+
49+
func TestListAudioSources(t *testing.T) {
50+
t.Parallel()
51+
t.Attr("component", "system")
52+
t.Attr("type", "integration")
53+
t.Attr("feature", "audio-sources")
54+
55+
t.Run("No engine returns empty list", func(t *testing.T) {
56+
e := echo.New()
57+
controller := &Controller{
58+
Echo: e,
59+
Group: e.Group("/api/v2"),
60+
Settings: &conf.Settings{},
61+
}
62+
63+
req := httptest.NewRequest(http.MethodGet, "/api/v2/system/audio/sources", http.NoBody)
64+
rec := httptest.NewRecorder()
65+
ctx := e.NewContext(req, rec)
66+
ctx.SetPath("/api/v2/system/audio/sources")
67+
68+
err := controller.ListAudioSources(ctx)
69+
require.NoError(t, err)
70+
assert.Equal(t, http.StatusOK, rec.Code)
71+
72+
var resp AudioSourceListResponse
73+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
74+
assert.Empty(t, resp.Sources)
75+
})
76+
77+
t.Run("Returns RTSP and audio card sources", func(t *testing.T) {
78+
sources := []*audiocore.SourceConfig{
79+
{
80+
DisplayName: "Backyard Mic",
81+
Type: audiocore.SourceTypeAudioCard,
82+
ConnectionString: "hw:0,0",
83+
SampleRate: 48000,
84+
BitDepth: 16,
85+
Channels: 1,
86+
},
87+
{
88+
DisplayName: "Front Camera",
89+
Type: audiocore.SourceTypeRTSP,
90+
ConnectionString: "rtsp://192.168.1.10:554/audio",
91+
SampleRate: 48000,
92+
BitDepth: 16,
93+
Channels: 1,
94+
},
95+
}
96+
e, controller := setupAudioSourcesTestEnv(t, sources)
97+
98+
req := httptest.NewRequest(http.MethodGet, "/api/v2/system/audio/sources", http.NoBody)
99+
rec := httptest.NewRecorder()
100+
ctx := e.NewContext(req, rec)
101+
ctx.SetPath("/api/v2/system/audio/sources")
102+
103+
err := controller.ListAudioSources(ctx)
104+
require.NoError(t, err)
105+
assert.Equal(t, http.StatusOK, rec.Code)
106+
107+
var resp AudioSourceListResponse
108+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
109+
assert.Len(t, resp.Sources, 2)
110+
111+
// Sorted by DisplayName
112+
assert.Equal(t, "Backyard Mic", resp.Sources[0].Name)
113+
assert.Equal(t, "audio_card", resp.Sources[0].Type)
114+
assert.Equal(t, "Front Camera", resp.Sources[1].Name)
115+
assert.Equal(t, "rtsp", resp.Sources[1].Type)
116+
})
117+
}
118+
119+
func TestListStreamSources(t *testing.T) {
120+
t.Parallel()
121+
t.Attr("component", "streams")
122+
t.Attr("type", "integration")
123+
t.Attr("feature", "stream-sources")
124+
125+
t.Run("No engine returns empty list", func(t *testing.T) {
126+
e := echo.New()
127+
controller := &Controller{
128+
Echo: e,
129+
Group: e.Group("/api/v2"),
130+
Settings: &conf.Settings{},
131+
}
132+
133+
req := httptest.NewRequest(http.MethodGet, "/api/v2/streams/sources", http.NoBody)
134+
rec := httptest.NewRecorder()
135+
ctx := e.NewContext(req, rec)
136+
ctx.SetPath("/api/v2/streams/sources")
137+
138+
err := controller.ListStreamSources(ctx)
139+
require.NoError(t, err)
140+
assert.Equal(t, http.StatusOK, rec.Code)
141+
142+
var resp AudioSourceListResponse
143+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
144+
assert.Empty(t, resp.Sources)
145+
})
146+
147+
t.Run("Filters to stream types only", func(t *testing.T) {
148+
sources := []*audiocore.SourceConfig{
149+
{
150+
DisplayName: "Backyard Mic",
151+
Type: audiocore.SourceTypeAudioCard,
152+
ConnectionString: "hw:0,0",
153+
SampleRate: 48000,
154+
BitDepth: 16,
155+
Channels: 1,
156+
},
157+
{
158+
DisplayName: "Front Camera",
159+
Type: audiocore.SourceTypeRTSP,
160+
ConnectionString: "rtsp://192.168.1.10:554/audio",
161+
SampleRate: 48000,
162+
BitDepth: 16,
163+
Channels: 1,
164+
},
165+
{
166+
DisplayName: "HTTP Stream",
167+
Type: audiocore.SourceTypeHTTP,
168+
ConnectionString: "http://192.168.1.20:8000/stream",
169+
SampleRate: 48000,
170+
BitDepth: 16,
171+
Channels: 1,
172+
},
173+
}
174+
e, controller := setupAudioSourcesTestEnv(t, sources)
175+
176+
req := httptest.NewRequest(http.MethodGet, "/api/v2/streams/sources", http.NoBody)
177+
rec := httptest.NewRecorder()
178+
ctx := e.NewContext(req, rec)
179+
ctx.SetPath("/api/v2/streams/sources")
180+
181+
err := controller.ListStreamSources(ctx)
182+
require.NoError(t, err)
183+
assert.Equal(t, http.StatusOK, rec.Code)
184+
185+
var resp AudioSourceListResponse
186+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
187+
assert.Len(t, resp.Sources, 2, "Should only include stream sources, not audio cards")
188+
189+
types := make([]string, len(resp.Sources))
190+
for i, s := range resp.Sources {
191+
types[i] = s.Type
192+
}
193+
assert.NotContains(t, types, "audio_card")
194+
})
195+
}

internal/api/v2/system.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ func (c *Controller) initSystemRoutes() {
407407
audioGroup.GET("/devices", c.GetAudioDevices)
408408
audioGroup.GET("/active", c.GetActiveAudioDevice)
409409
audioGroup.GET("/equalizer/config", c.GetEqualizerConfig)
410+
audioGroup.GET("/sources", c.ListAudioSources)
410411

411412
// Events routes (detection lifecycle + operational logs)
412413
c.registerEventsRoutes(protectedGroup)

0 commit comments

Comments
 (0)