mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
feat(radio): add radio metadata lifecycle API
Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com>
This commit is contained in:
parent
98b30da586
commit
8f70e0e4f3
@ -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()
|
||||
|
||||
@ -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
14
core/radio/provider.go
Normal 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)
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
140
server/nativeapi/radio_nowplaying_test.go
Normal file
140
server/nativeapi/radio_nowplaying_test.go
Normal 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)
|
||||
}
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user