feat(jellyfin): emit refreshResource events on favorite/rating changes

Like Subsonic's setStar/setRating, the Jellyfin favorite and rating
endpoints now broadcast a refreshResource event, so the web UI updates
immediately when a Jellyfin client changes an annotation.

Also fixes model.GetEntityByID to propagate unexpected repository errors
instead of reporting them as not-found, preserving the 500-vs-404
distinction for all its callers.
This commit is contained in:
Deluan 2026-07-18 16:49:31 -04:00
parent f61b4eee21
commit e4a423db11
10 changed files with 163 additions and 71 deletions

View File

@ -138,7 +138,7 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher) sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager) lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics) router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
return router return router
} }

View File

@ -2,29 +2,26 @@ package model
import ( import (
"context" "context"
"errors"
) )
// TODO: Should the type be encoded in the ID? // TODO: Should the type be encoded in the ID?
func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
ar, err := ds.Artist(ctx).Get(id) getters := []func() (any, error){
if err == nil { func() (any, error) { return ds.Artist(ctx).Get(id) },
return ar, nil func() (any, error) { return ds.Album(ctx).Get(id) },
func() (any, error) { return ds.Playlist(ctx).Get(id) },
func() (any, error) { return ds.MediaFile(ctx).Get(id) },
func() (any, error) { return ds.Radio(ctx).Get(id) },
} }
al, err := ds.Album(ctx).Get(id) for _, get := range getters {
if err == nil { entity, err := get()
return al, nil if err == nil {
return entity, nil
}
if !errors.Is(err, ErrNotFound) {
return nil, err
}
} }
pls, err := ds.Playlist(ctx).Get(id) return nil, ErrNotFound
if err == nil {
return pls, nil
}
mf, err := ds.MediaFile(ctx).Get(id)
if err == nil {
return mf, nil
}
r, err := ds.Radio(ctx).Get(id)
if err == nil {
return r, nil
}
return nil, err
} }

40
model/get_entity_test.go Normal file
View File

@ -0,0 +1,40 @@
package model_test
import (
"context"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GetEntityByID", func() {
var ds *tests.MockDataStore
var ctx context.Context
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = GinkgoT().Context()
})
It("returns the entity matching the id", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
entity, err := model.GetEntityByID(ctx, ds, "a1")
Expect(err).ToNot(HaveOccurred())
Expect(entity).To(BeAssignableToTypeOf(&model.Album{}))
Expect(entity.(*model.Album).ID).To(Equal("a1"))
})
It("returns ErrNotFound when no entity matches", func() {
_, err := model.GetEntityByID(ctx, ds, "missing")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("propagates unexpected repository errors instead of reporting not-found", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true)
_, err := model.GetEntityByID(ctx, ds, "a1")
Expect(err).To(HaveOccurred())
Expect(err).ToNot(MatchError(model.ErrNotFound))
})
})

View File

@ -8,53 +8,41 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/req"
) )
// resolveAnnotated finds which annotated repo owns id. Albums and songs 404 when the user can't // resolveAnnotated finds which annotated repo owns id, returning the resource name used in
// access their library; artists span libraries (library_artist), so have no single LibraryID to // refreshResource events. Albums and songs 404 when the user can't access their library; artists
// gate on and rely on list-time scoping. PlaylistRepository.Get enforces playlist visibility. // span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time
// When ok is false the response has already been written, so callers must return without writing // scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has
// the annotation. // already been written, so callers must return without writing the annotation.
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, ok bool) { func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) {
ctx := r.Context() ctx := r.Context()
entity, err := model.GetEntityByID(ctx, api.ds, id)
if err != nil && !errors.Is(err, model.ErrNotFound) {
api.internalError(w, r, err)
return nil, ""
}
u, _ := request.UserFrom(ctx) u, _ := request.UserFrom(ctx)
if al, err := api.ds.Album(ctx).Get(id); err == nil { switch e := entity.(type) {
if !u.HasLibraryAccess(al.LibraryID) { case *model.Album:
http.Error(w, "Not Found", http.StatusNotFound) if u.HasLibraryAccess(e.LibraryID) {
return nil, false return api.ds.Album(ctx), "album"
} }
return api.ds.Album(ctx), true case *model.Artist:
} else if !errors.Is(err, model.ErrNotFound) { return api.ds.Artist(ctx), "artist"
api.internalError(w, r, err) case *model.MediaFile:
return nil, false if u.HasLibraryAccess(e.LibraryID) {
} return api.ds.MediaFile(ctx), "song"
if _, err := api.ds.Artist(ctx).Get(id); err == nil {
return api.ds.Artist(ctx), true
} else if !errors.Is(err, model.ErrNotFound) {
api.internalError(w, r, err)
return nil, false
}
if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil {
if !u.HasLibraryAccess(mf.LibraryID) {
http.Error(w, "Not Found", http.StatusNotFound)
return nil, false
} }
return api.ds.MediaFile(ctx), true case *model.Playlist:
} else if !errors.Is(err, model.ErrNotFound) { return api.ds.Playlist(ctx), "playlist"
api.internalError(w, r, err)
return nil, false
}
playlistRepo := api.ds.Playlist(ctx)
if _, err := playlistRepo.Get(id); err == nil {
return playlistRepo, true
} else if !errors.Is(err, model.ErrNotFound) {
api.internalError(w, r, err)
return nil, false
} }
// Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent.
http.Error(w, "Not Found", http.StatusNotFound) http.Error(w, "Not Found", http.StatusNotFound)
return nil, false return nil, ""
} }
// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify // getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify
@ -77,14 +65,15 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
repo, ok := api.resolveAnnotated(w, r, id) repo, resource := api.resolveAnnotated(w, r, id)
if !ok { if repo == nil {
return return
} }
if err := repo.SetStar(starred, id); err != nil { if err := repo.SetStar(starred, id); err != nil {
api.internalError(w, r, err) api.internalError(w, r, err)
return return
} }
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
encodedID := dto.EncodeID(id) encodedID := dto.EncodeID(id)
api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID}) api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID})
} }
@ -96,14 +85,15 @@ func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
repo, ok := api.resolveAnnotated(w, r, id) repo, resource := api.resolveAnnotated(w, r, id)
if !ok { if repo == nil {
return return
} }
if err := repo.SetRating(rating, id); err != nil { if err := repo.SetRating(rating, id); err != nil {
api.internalError(w, r, err) api.internalError(w, r, err)
return return
} }
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
encodedID := dto.EncodeID(id) encodedID := dto.EncodeID(id)
d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID} d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID}
if rating > 0 { if rating > 0 {

View File

@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
@ -17,6 +18,7 @@ import (
var _ = Describe("Annotations", func() { var _ = Describe("Annotations", func() {
var api *Router var api *Router
var ds *tests.MockDataStore var ds *tests.MockDataStore
var broker *fakeEventBroker
// alice has access to library 1 only. // alice has access to library 1 only.
ctxUser := func() context.Context { ctxUser := func() context.Context {
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
@ -24,7 +26,8 @@ var _ = Describe("Annotations", func() {
BeforeEach(func() { BeforeEach(func() {
ds = &tests.MockDataStore{} ds = &tests.MockDataStore{}
api = &Router{ds: ds} broker = &fakeEventBroker{}
api = &Router{ds: ds, broker: broker}
}) })
Describe("markFavorite / unmarkFavorite", func() { Describe("markFavorite / unmarkFavorite", func() {
@ -134,6 +137,39 @@ var _ = Describe("Annotations", func() {
invoke(api.markFavorite, w, r) invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusInternalServerError)) Expect(w.Code).To(Equal(http.StatusInternalServerError))
}) })
It("emits a refreshResource event when starring a song", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
invoke(api.markFavorite, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`))
})
It("emits a refreshResource event when starring an album", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
invoke(api.markFavorite, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["a1"]}`))
})
It("does not emit an event when the item is not accessible", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(broker.Events).To(BeEmpty())
})
}) })
Describe("setRating / removeRating", func() { Describe("setRating / removeRating", func() {
@ -253,5 +289,31 @@ var _ = Describe("Annotations", func() {
Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) Expect(mfRepo.Data["s1"].Rating).To(Equal(0))
}) })
It("emits a refreshResource event when rating a song", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
invoke(api.setRating, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`))
})
}) })
}) })
type fakeEventBroker struct {
http.Handler
Events []events.Event
}
func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
f.Events = append(f.Events, event)
}
func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
f.Events = append(f.Events, event)
}
var _ events.Broker = (*fakeEventBroker)(nil)

View File

@ -22,6 +22,7 @@ import (
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/cache"
) )
@ -38,6 +39,7 @@ type Router struct {
provider external.Provider provider external.Provider
sonic sonic.Engine sonic sonic.Engine
lyrics lyrics.Lyrics lyrics lyrics.Lyrics
broker events.Broker
lyricsCache cache.SimpleCache[string, model.LyricList] lyricsCache cache.SimpleCache[string, model.LyricList]
similarFlight singleflight.Group similarFlight singleflight.Group
serverIDMu sync.Mutex serverIDMu sync.Mutex
@ -47,11 +49,11 @@ type Router struct {
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer,
transcodeDecider stream.TranscodeDecider, players core.Players, transcodeDecider stream.TranscodeDecider, players core.Players,
scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider,
sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics) *Router { sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router {
r := &Router{ r := &Router{
ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider,
players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider,
sonic: sonicSvc, lyrics: lyricsSvc, sonic: sonicSvc, lyrics: lyricsSvc, broker: broker,
lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{
SizeLimit: 1000, SizeLimit: 1000,
DefaultTTL: 5 * time.Minute, DefaultTTL: 5 * time.Minute,

View File

@ -18,7 +18,7 @@ import (
var _ = Describe("Router", func() { var _ = Describe("Router", func() {
It("serves the public handshake through the mounted handler", func() { It("serves the public handshake through the mounted handler", func() {
ds := &tests.MockDataStore{} ds := &tests.MockDataStore{}
api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil) api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Info/Public", nil) r := httptest.NewRequest("GET", "/System/Info/Public", nil)
api.ServeHTTP(w, r) api.ServeHTTP(w, r)
@ -26,7 +26,7 @@ var _ = Describe("Router", func() {
}) })
It("returns 404 JSON for unknown routes", func() { It("returns 404 JSON for unknown routes", func() {
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) r := httptest.NewRequest("GET", "/Nonexistent/Route", nil)
api.ServeHTTP(w, r) api.ServeHTTP(w, r)
@ -36,7 +36,7 @@ var _ = Describe("Router", func() {
}) })
It("returns 404 JSON for a known path with an unsupported method", func() { It("returns 404 JSON for a known path with an unsupported method", func() {
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) r := httptest.NewRequest("PATCH", "/System/Info/Public", nil)
api.ServeHTTP(w, r) api.ServeHTTP(w, r)
@ -53,7 +53,7 @@ var _ = Describe("Router", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
fp := &fakePlayers{} fp := &fakePlayers{}
api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil) api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Users/Me", nil) r := httptest.NewRequest("GET", "/Users/Me", nil)
@ -70,7 +70,7 @@ var _ = Describe("Router", func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 2 conf.Server.AuthRequestLimit = 2
conf.Server.AuthWindowLength = time.Minute conf.Server.AuthWindowLength = time.Minute
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
login := func() int { login := func() int {
w := httptest.NewRecorder() w := httptest.NewRecorder()

View File

@ -329,6 +329,7 @@ func setupTestDB() {
providerFake, providerFake,
sonicSvc, sonicSvc,
lyrics.NewLyrics(ds, nil), lyrics.NewLyrics(ds, nil),
events.NoopBroker(),
) )
} }

View File

@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() {
var api *Router var api *Router
BeforeEach(func() { BeforeEach(func() {
api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
}) })
It("serves a fully lowercase path directly", func() { It("serves a fully lowercase path directly", func() {

View File

@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
token = t token = t
api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil) api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
}) })
It("upgrades when authenticated via the api_key query parameter", func() { It("upgrades when authenticated via the api_key query parameter", func() {