From a2b8d8c0d01f375403f1668b41f34a2bf8e5dd4c Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 6 Jul 2026 07:45:25 -0400 Subject: [PATCH] fix(jellyfin): artist search 500 (library filter leaked into FTS query) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getArtists/listArtists reused the browse-path filters (notMissing + ApplyArtistLibraryFilter) for the search path, but artist Search expects a sole Eq{library_id} filter it can consume as a scope — artists have no library_id column, so the compound/join filter leaked into the FTS query and 500'd. Every Finamp artist search failed (the filter is applied even unscoped, since admins resolve to all library ids). Build search filters separately. Adds e2e search coverage. --- server/jellyfin/e2e/search_test.go | 43 ++++++++++++++++++++++++++++++ server/jellyfin/items.go | 26 ++++++++++++------ 2 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 server/jellyfin/e2e/search_test.go diff --git a/server/jellyfin/e2e/search_test.go b/server/jellyfin/e2e/search_test.go new file mode 100644 index 000000000..38138d342 --- /dev/null +++ b/server/jellyfin/e2e/search_test.go @@ -0,0 +1,43 @@ +package e2e + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Search with a ParentId library scope is how Finamp drives its search screen. Artists are the +// tricky case: they have no library_id column, so the repo's Search does its own scope handling. +var _ = Describe("Search", func() { + BeforeEach(func() { setupTestDB() }) + + lib1 := func() string { return enc("1") } // Library id 1 encodes to "31" + + Describe("artists", func() { + It("searches all album artists", func() { + q := queryResult(get("/Artists/AlbumArtists?SearchTerm=Beatles")) + Expect(names(q.Items)).To(ConsistOf("The Beatles")) + }) + + It("searches album artists scoped to a library (ParentId)", func() { + q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1() + "&SearchTerm=Beatles&Recursive=true&SortBy=SortName")) + Expect(names(q.Items)).To(ConsistOf("The Beatles")) + }) + + It("returns an empty result for a non-matching term", func() { + q := queryResult(get("/Artists?ParentId=" + lib1() + "&SearchTerm=nonexistentxyz")) + Expect(q.Items).To(BeEmpty()) + }) + }) + + Describe("albums and songs", func() { + It("searches albums scoped to a library", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Abbey")) + Expect(names(q.Items)).To(ContainElement("Abbey Road")) + }) + + It("searches songs scoped to a library", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Stairway")) + Expect(names(q.Items)).To(ContainElement("Stairway To Heaven")) + }) + }) +}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 7ee2335ad..1dd2c48b5 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -197,20 +197,30 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, paren func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) { repo := api.ds.Artist(ctx) + + // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter + // (artists have no library_id column, so it can't be a real WHERE clause) and realizes it as a + // search scope. The join-based ApplyArtistLibraryFilter, or any compound filter, would leak + // library_artist.library_id into the FTS query and 500. So the search and browse paths build + // their filters differently. See persistence/artist_repository.go's searchScope. + if search != "" { + if len(scopeIDs) > 0 { + opts.Filters = squirrel.Eq{"library_id": scopeIDs} + } + artists, err := repo.Search(search, opts) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(artists, dto.ArtistToBaseItem), len(artists), opts.Offset), nil + } + if fav { opts.Filters = filter.ArtistsByStarred().Filters } else { opts.Filters = notMissing } opts = filter.ApplyArtistLibraryFilter(opts, scopeIDs) - - var artists model.Artists - var err error - if search != "" { - artists, err = repo.Search(search, opts) - } else { - artists, err = repo.GetAll(opts) - } + artists, err := repo.GetAll(opts) if err != nil { return dto.QueryResult{}, err }