feat(radio): add radio metadata lifecycle API

Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com>
This commit is contained in:
Yuko 2026-06-30 23:28:55 +02:00 committed by hiyukoim
parent 98b30da586
commit 8f70e0e4f3
12 changed files with 242 additions and 9 deletions

View File

@ -21,6 +21,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/radio"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
@ -83,7 +84,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
user := core.NewUser(dataStore, manager)
maintenance := core.NewMaintenance(dataStore)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService)
metadataManager := radio.NewMetadataManagerService()
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService, metadataManager)
return router
}
@ -247,7 +249,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(nativeapi.RadioMetadataManager), new(*radio.MetadataManager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/radio"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/db"
@ -53,6 +54,7 @@ var allProviders = wire.NewSet(
wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(sonic.Engine), new(*sonic.Sonic)),
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
wire.Bind(new(nativeapi.RadioMetadataManager), new(*radio.MetadataManager)),
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
wire.Bind(new(core.Watcher), new(scanner.Watcher)),

14
core/radio/provider.go Normal file
View File

@ -0,0 +1,14 @@
package radio
import (
"context"
"net/http"
"github.com/navidrome/navidrome/core/radio/icy"
)
func NewMetadataManagerService() *MetadataManager {
return NewMetadataManager(func(ctx context.Context, streamURL string, handleTitle func(string)) error {
return icy.ReadHTTPStreamTitles(ctx, http.DefaultClient, streamURL, handleTitle)
}, nil)
}

View File

@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/radio"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/stream"
)
@ -36,4 +37,5 @@ var Set = wire.NewSet(
playback.GetInstance,
metrics.GetInstance,
lyrics.NewLyrics,
radio.NewMetadataManagerService,
)

View File

@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users

View File

@ -31,7 +31,7 @@ var _ = Describe("Library API", func() {
conf.Server.EnableSharing = false
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users

View File

@ -15,6 +15,7 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/metrics"
playlistsvc "github.com/navidrome/navidrome/core/playlists"
coreradio "github.com/navidrome/navidrome/core/radio"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -34,6 +35,11 @@ type PluginManager interface {
UnloadDisabledPlugins(ctx context.Context)
}
type RadioMetadataManager interface {
Start(ctx context.Context, sessionID string, station coreradio.Station) error
Stop(sessionID string)
}
type Router struct {
http.Handler
ds model.DataStore
@ -45,10 +51,11 @@ type Router struct {
maintenance core.Maintenance
pluginManager PluginManager
imgUpload core.ImageUploadService
radioMetadata RadioMetadataManager
}
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router {
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload}
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService, radioMetadata RadioMetadataManager) *Router {
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, radioMetadata: radioMetadata}
r.Handler = r.routes()
return r
}

View File

@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
mfRepo.SetData(testSongs)
// Create the native API router and wrap it with the JWTVerifier middleware
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})

View File

@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
err := userRepo.Put(&testUser)
Expect(err).ToNot(HaveOccurred())
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil)
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})

View File

@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() {
ds = &tests.MockDataStore{}
mockManager = &tests.MockPluginManager{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users

View File

@ -0,0 +1,140 @@
package nativeapi
import (
"context"
"net/http"
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
coreradio "github.com/navidrome/navidrome/core/radio"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Radio now-playing metadata endpoints", func() {
var (
router http.Handler
ds *tests.MockDataStore
radioRepo *tests.MockedRadioRepo
userRepo *tests.MockedUserRepo
manager *fakeRadioMetadataManager
testUser model.User
authRequest func(method, path string) *http.Request
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Minute
radioRepo = &tests.MockedRadioRepo{
Data: map[string]*model.Radio{
"rd-1": {
ID: "rd-1",
Name: "Test Radio",
StreamUrl: "https://stream.example.test/radio",
},
},
}
userRepo = tests.CreateMockUserRepo()
ds = &tests.MockDataStore{
MockedRadio: radioRepo,
MockedUser: userRepo,
MockedProperty: &tests.MockedPropertyRepo{},
}
auth.Init(ds)
testUser = model.User{
ID: "user-1",
UserName: "testuser",
Name: "Test User",
IsAdmin: false,
NewPassword: "testpass",
}
Expect(userRepo.Put(&testUser)).To(Succeed())
manager = &fakeRadioMetadataManager{}
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, manager)
router = server.JWTVerifier(nativeRouter)
authRequest = func(method, path string) *http.Request {
req := httptest.NewRequest(method, path, nil)
token, err := auth.CreateToken(&testUser)
Expect(err).ToNot(HaveOccurred())
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
return req.WithContext(request.WithClientUniqueId(req.Context(), "client-1"))
}
})
It("starts metadata reading for a stored radio stream URL", func() {
w := httptest.NewRecorder()
req := authRequest(http.MethodPost, "/radio/rd-1/nowplaying")
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(manager.starts).To(Equal([]metadataStart{{
sessionID: "user-1:client-1",
station: coreradio.Station{
ID: "rd-1",
StreamURL: "https://stream.example.test/radio",
},
}}))
})
It("returns not found when starting metadata for a missing radio", func() {
w := httptest.NewRecorder()
req := authRequest(http.MethodPost, "/radio/missing/nowplaying")
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(manager.starts).To(BeEmpty())
})
It("returns unauthorized when request is not authenticated", func() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/radio/rd-1/nowplaying", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(manager.starts).To(BeEmpty())
})
It("stops metadata reading idempotently for the active session", func() {
w := httptest.NewRecorder()
req := authRequest(http.MethodDelete, "/radio/rd-1/nowplaying")
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(manager.stops).To(Equal([]string{"user-1:client-1"}))
})
})
type metadataStart struct {
sessionID string
station coreradio.Station
}
type fakeRadioMetadataManager struct {
starts []metadataStart
stops []string
}
func (m *fakeRadioMetadataManager) Start(_ context.Context, sessionID string, station coreradio.Station) error {
m.starts = append(m.starts, metadataStart{sessionID: sessionID, station: station})
return nil
}
func (m *fakeRadioMetadataManager) Stop(sessionID string) {
m.stops = append(m.stops, sessionID)
}

View File

@ -9,7 +9,9 @@ import (
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/consts"
coreradio "github.com/navidrome/navidrome/core/radio"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
)
@ -27,10 +29,74 @@ func (api *Router) addRadioRoute(r chi.Router) {
r.Delete("/", rest.Delete(constructor))
r.Post("/image", api.uploadRadioImage())
r.Delete("/image", api.deleteRadioImage())
r.Post("/nowplaying", api.startRadioNowPlaying())
r.Delete("/nowplaying", api.stopRadioNowPlaying())
})
})
}
func (api *Router) startRadioNowPlaying() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if api.radioMetadata == nil {
http.Error(w, "radio metadata is unavailable", http.StatusServiceUnavailable)
return
}
radioID := chi.URLParam(r, "id")
radio, err := api.ds.Radio(r.Context()).Get(radioID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = api.radioMetadata.Start(r.Context(), radioNowPlayingSessionID(r.Context()), coreradio.Station{
ID: radio.ID,
StreamURL: radio.StreamUrl,
})
if err != nil {
if errors.Is(err, coreradio.ErrInvalidSession) || errors.Is(err, coreradio.ErrInvalidStation) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func (api *Router) stopRadioNowPlaying() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if api.radioMetadata == nil {
http.Error(w, "radio metadata is unavailable", http.StatusServiceUnavailable)
return
}
api.radioMetadata.Stop(radioNowPlayingSessionID(r.Context()))
w.WriteHeader(http.StatusNoContent)
}
}
func radioNowPlayingSessionID(ctx context.Context) string {
user, hasUser := request.UserFrom(ctx)
clientID, _ := request.ClientUniqueIdFrom(ctx)
if clientID == "" {
clientID = "default"
}
if hasUser && user.ID != "" {
return user.ID + ":" + clientID
}
if username, ok := request.UsernameFrom(ctx); ok && username != "" {
return username + ":" + clientID
}
return clientID
}
func (api *Router) uploadRadioImage() http.HandlerFunc {
return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error {
radioID := chi.URLParamFromCtx(ctx, "id")