fix(jellyfin): resolve genre id as a MusicGenre item

The Jellyfin /Items/{id} endpoint resolved albums, artists, songs and
playlists by id but not genres, so a genre id returned 404. Finamp's
genre "See all" fetches the genre as the track list's parent item, and
that 404 crashed its screen to a blank page after the tracks flashed in.

Add a Genre lookup to resolveItemByID (backed by a new GenreRepository.Get)
so a genre id returns its MusicGenre BaseItemDto, matching real Jellyfin.
This commit is contained in:
Deluan 2026-08-10 21:30:14 -04:00
parent d080707060
commit 8e0ff1a235
5 changed files with 38 additions and 4 deletions

View File

@ -11,4 +11,5 @@ type Genres []Genre
type GenreRepository interface {
GetAll(...QueryOptions) (Genres, error)
Get(id string) (*Genre, error)
}

View File

@ -30,15 +30,19 @@ func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error
return res, err
}
// Override ResourceRepository methods to return Genre objects instead of Tag objects
func (r *genreRepository) Read(id string) (any, error) {
func (r *genreRepository) Get(id string) (*model.Genre, error) {
sel := r.selectGenre().Where(Eq{"tag.id": id})
var res model.Genre
err := r.queryOne(sel, &res)
return &res, err
}
// Override ResourceRepository methods to return Genre objects instead of Tag objects
func (r *genreRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}

View File

@ -736,7 +736,7 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q
}
// resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album,
// artist, song and playlist in turn. Albums and songs report not-found when the user lacks access
// artist, song, playlist and genre in turn. Albums and songs report not-found when the user lacks access
// to their library, so an id can't probe content outside the user's libraries.
func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) {
// The synthetic playlists folder must resolve by the id we advertised, not 404.
@ -778,6 +778,9 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi
if pl, err := api.playlists.Get(ctx, id); err == nil {
return dto.PlaylistToBaseItem(*pl, fields), true
}
if g, err := api.ds.Genre(ctx).Get(id); err == nil {
return dto.GenreToBaseItem(*g), true
}
return dto.BaseItemDto{}, false
}

View File

@ -917,6 +917,21 @@ var _ = Describe("Items", func() {
Expect(w.Code).To(Equal(http.StatusNotFound))
})
// Finamp's genre "See all" fetches the genre by id; a 404 white-screens it (see resolveItemByID).
It("resolves a genre id as a MusicGenre item", func() {
Expect(ds.Genre(context.Background()).(*tests.MockedGenreRepo).Put(&model.Genre{ID: "g1", Name: "Rock"})).To(Succeed())
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("g1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("g1"))
invoke(api.getItem, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var item dto.BaseItemDto
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
Expect(item.Id).To(Equal(dto.EncodeID("g1")))
Expect(item.Name).To(Equal("Rock"))
Expect(item.Type).To(Equal("MusicGenre"))
})
It("resolves a library-view id for an admin even though their Libraries slice is empty", func() {
ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}})
w := httptest.NewRecorder()

View File

@ -32,6 +32,17 @@ func (r *MockedGenreRepo) GetAll(options ...model.QueryOptions) (model.Genres, e
return all, nil
}
func (r *MockedGenreRepo) Get(id string) (*model.Genre, error) {
if r.Error != nil {
return nil, r.Error
}
r.init()
if g, ok := r.Data[id]; ok {
return &g, nil
}
return nil, model.ErrNotFound
}
func (r *MockedGenreRepo) Put(g *model.Genre) error {
if r.Error != nil {
return r.Error