From 263eeda6fb589f38f4f26ebb4d8fc77f2819c1c3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 6 Jul 2026 11:23:17 -0400 Subject: [PATCH] fix(jellyfin): separate "Featured On" from an artist's own discography MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jellify's artist page fetches the discography via albumArtistIds and the "Featured On" section via contributingArtistIds, relying on the server to return disjoint sets. We collapsed albumArtistIds/artistIds/ contributingArtistIds into one AlbumsByArtistID filter, so an artist's own albums appeared in both sections. Add AlbumsByContributingArtistID — albums where the artist is a track artist but NOT the album artist — matching Jellyfin's ContributingArtistIds (in Artists, not in AlbumArtists), and route contributingArtistIds to it. --- server/filter/filters.go | 14 +++++++++++ server/jellyfin/e2e/browsing_test.go | 13 +++++++++++ server/jellyfin/items.go | 35 +++++++++++++++++----------- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/server/filter/filters.go b/server/filter/filters.go index 930a192d5..56ee3aecb 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -61,6 +61,20 @@ func AlbumsByArtistID(artistId string) Options { }) } +// AlbumsByContributingArtistID matches albums where the artist appears as a track (performing) +// artist but is NOT the album artist — Jellyfin's ContributingArtistIds / the "Featured On" +// section. It is the disjoint complement of AlbumsByArtistID's album-artist match, so an artist's +// own discography never leaks into their "appears on" list. +func AlbumsByContributingArtistID(artistId string) Options { + return addDefaultFilters(Options{ + Sort: "max_year", + Filters: And{ + persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}), + persistence.NotExists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}), + }, + }) +} + func AlbumsByYear(fromYear, toYear int) Options { orderOption := "" if fromYear > toYear { diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 0f8f27e9d..8c4bf50a3 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -117,6 +117,19 @@ var _ = Describe("Browsing", func() { q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("Miles Davis")))) Expect(names(q.Items)).To(ConsistOf("So What")) }) + + // contributingArtistIds is Jellify's "Featured On" section: albums the artist only appears + // on, which must exclude their own discography (albums where they are the album artist). + It("lists Featured On albums (contributingArtistIds) a performer only guests on", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("Featured Guest")))) + Expect(names(q.Items)).To(ConsistOf("Singles")) + }) + + It("excludes an album artist's own discography from Featured On (contributingArtistIds)", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).ToNot(ContainElement("Abbey Road")) + Expect(names(q.Items)).ToNot(ContainElement("Help!")) + }) }) // Jellify (and the official Jellyfin TypeScript SDK) send query params in camelCase diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index ddc449b34..287f22bc3 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -65,11 +65,14 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryRe // An artist's page filters by artist, not by ParentId: Finamp sends ParentId= for // scoping plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist itself. Without // this, an artist's albums/tracks come back unfiltered (all artists). - artistId := firstDecodedID(firstNonEmpty( - p.StringOr("albumartistids", ""), - p.StringOr("artistids", ""), - p.StringOr("contributingartistids", ""), - )) + // + // albumArtistIds/artistIds select the artist's own discography; contributingArtistIds alone + // selects albums the artist merely appears on (Jellyfin's "Featured On"), which must exclude + // that discography — otherwise their own albums show up in both sections. + albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) + contributingScope := p.StringOr("contributingartistids", "") + artistId := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + contributingOnly := albumArtistScope == "" && contributingScope != "" scopeIDs, isLibraryParent := resolveLibraryScope(ctx, parentId) // When no item type is requested and ParentId is an album, browse into that album's tracks: @@ -92,7 +95,7 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryRe if len(types) == 1 { opts := model.QueryOptions{Offset: offset, Max: limit} applySort(&opts, types[0], sortBy, sortOrder) - return api.queryItemsOfType(ctx, types[0], opts, entityParent, artistId, scopeIDs, search, favOnly) + return api.queryItemsOfType(ctx, types[0], opts, entityParent, artistId, contributingOnly, scopeIDs, search, favOnly) } var items []dto.BaseItemDto @@ -100,7 +103,7 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryRe for _, itemType := range types { var opts model.QueryOptions applySort(&opts, itemType, sortBy, sortOrder) - res, err := api.queryItemsOfType(ctx, itemType, opts, entityParent, artistId, scopeIDs, search, favOnly) + res, err := api.queryItemsOfType(ctx, itemType, opts, entityParent, artistId, contributingOnly, scopeIDs, search, favOnly) if err != nil { return dto.QueryResult{}, err } @@ -110,7 +113,7 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryRe return result(paginate(items, offset, limit), total, offset), nil } -func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, entityParent, artistId string, scopeIDs []int, search string, favOnly bool) (dto.QueryResult, error) { +func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, entityParent, artistId string, contributingOnly bool, scopeIDs []int, search string, favOnly bool) (dto.QueryResult, error) { switch itemType { case "Audio": return api.listSongs(ctx, opts, entityParent, artistId, scopeIDs, search, favOnly) @@ -122,7 +125,7 @@ func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts m case "Playlist": return api.listPlaylists(ctx, opts, favOnly) default: // MusicAlbum - return api.listAlbums(ctx, opts, entityParent, artistId, scopeIDs, search, favOnly) + return api.listAlbums(ctx, opts, entityParent, artistId, contributingOnly, scopeIDs, search, favOnly) } } @@ -180,14 +183,18 @@ func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { return items } -func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, parentId, artistId string, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) { +func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, parentId, artistId string, contributingOnly bool, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) { repo := api.ds.Album(ctx) filters := squirrel.And{} // For albums, both ParentId (browse into an artist) and AlbumArtistIds/ArtistIds mean "this - // artist's albums". - if scope := firstNonEmpty(artistId, parentId); scope != "" { - filters = append(filters, filter.AlbumsByArtistID(scope).Filters) - } else { + // artist's albums"; contributingArtistIds instead means "albums this artist only appears on" + // (Featured On), which excludes their own discography. + switch { + case contributingOnly && artistId != "": + filters = append(filters, filter.AlbumsByContributingArtistID(artistId).Filters) + case firstNonEmpty(artistId, parentId) != "": + filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(artistId, parentId)).Filters) + default: filters = append(filters, notMissing) } if fav {