diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 463ac7a44..4588d5810 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -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() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 94faa5af3..4f3ff2a29 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -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)), diff --git a/core/radio/provider.go b/core/radio/provider.go new file mode 100644 index 000000000..5333bc9ac --- /dev/null +++ b/core/radio/provider.go @@ -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) +} diff --git a/core/wire_providers.go b/core/wire_providers.go index a2fffa34f..b98c13d91 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -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, ) diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index 107b01e01..d1007f457 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -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 diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 9b7061845..13b33c238 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -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 diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 5a7023eb6..8b7c930f9 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -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 } diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index b1ed09d65..203fcd4cf 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -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() }) diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 9bf502687..bc7c74efc 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -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() }) diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index aa91a7951..1683885e7 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -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 diff --git a/server/nativeapi/radio_nowplaying_test.go b/server/nativeapi/radio_nowplaying_test.go new file mode 100644 index 000000000..0b7faec10 --- /dev/null +++ b/server/nativeapi/radio_nowplaying_test.go @@ -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) +} diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go index 701c6c926..0b7bd5d3a 100644 --- a/server/nativeapi/radios.go +++ b/server/nativeapi/radios.go @@ -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")