From 176a95a94683e558577201453097ebf23a4555ea Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Wed, 2 Jul 2025 07:44:57 -0700 Subject: [PATCH] Compilation support for Jellyfin artist albums, misc other album filter fixes - Jellyfin will use `ContributingArtistsId` (compilation), `AlbumArtistIds` (compilation is false), or `ArtistIds` (unspecified; all) - Jellyfin can filter by compilation _only_ on the artist discography page - Navidrome album filter fix for `defaultValue` display and prevent showing `tagQuery` 0 when querying - Subsonic can filter by one or more artists in the album page. Sort is also applied on these items - Bump genre/tag cache/stale time to 2/1 minutes - Fix various cases where the album filter would display as active when it wasn't --- .../api/jellyfin/jellyfin-controller.ts | 29 +++++-- .../api/subsonic/subsonic-controller.ts | 4 +- .../components/album-list-header-filters.tsx | 20 +++-- .../components/jellyfin-album-filters.tsx | 83 +++++++++++++------ .../components/navidrome-album-filters.tsx | 15 ++-- .../components/subsonic-album-filters.tsx | 62 +++++++++++++- .../components/navidrome-song-filters.tsx | 1 + 7 files changed, 167 insertions(+), 47 deletions(-) diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 54f1f0b1..6aaff432 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -290,19 +290,32 @@ export const JellyfinController: ControllerEndpoint = { const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined; + let artistQuery: + | Omit, 'IncludeItemTypes'> + | undefined; + + if (query.artistIds) { + // Based mostly off of observation, this is the behavior I've seen: + // ContributingArtistIds is the _closest_ to where the album is a compilation and the artist is involved + // AlbumArtistIds is where the artist is an album artist + // ArtistIds is all credits + if (query.compilation) { + artistQuery = { + ContributingArtistIds: formatCommaDelimitedString(query.artistIds), + }; + } else if (query.compilation === false) { + artistQuery = { AlbumArtistIds: formatCommaDelimitedString(query.artistIds) }; + } else { + artistQuery = { ArtistIds: formatCommaDelimitedString(query.artistIds) }; + } + } + const res = await jfApiClient(apiClientProps).getAlbumList({ params: { userId: apiClientProps.server?.userId, }, query: { - ...(!query.compilation && - query.artistIds && { - AlbumArtistIds: formatCommaDelimitedString(query.artistIds), - }), - ...(query.compilation && - query.artistIds && { - ContributingArtistIds: query.artistIds[0], - }), + ...artistQuery, Fields: 'People, Tags', GenreIds: query.genres ? query.genres.join(',') : undefined, IncludeItemTypes: 'MusicAlbum', diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 73db90ca..0803936f 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -316,8 +316,10 @@ export const SubsonicController: ControllerEndpoint = { return artist.body.artist.album ?? []; }); + const items = albums.map((album) => ssNormalize.album(album, apiClientProps.server)); + return { - items: albums.map((album) => ssNormalize.album(album, apiClientProps.server)), + items: sortAlbumList(items, query.sortBy, query.sortOrder), startIndex: 0, totalRecordCount: albums.length, }; diff --git a/src/renderer/features/albums/components/album-list-header-filters.tsx b/src/renderer/features/albums/components/album-list-header-filters.tsx index d51cd639..5c81d3db 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -405,31 +405,35 @@ export const AlbumListHeaderFilters = ({ const isFilterApplied = useMemo(() => { const isNavidromeFilterApplied = server?.type === ServerType.NAVIDROME && - filter?._custom?.navidrome && - Object.values(filter?._custom?.navidrome).some((value) => value !== undefined); + ((filter?._custom?.navidrome && + Object.values(filter?._custom?.navidrome).some((value) => value !== undefined)) || + // Compilation is always valid + filter.compilation !== undefined); const isJellyfinFilterApplied = server?.type === ServerType.JELLYFIN && - filter?._custom?.jellyfin && - Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined); + ((filter?._custom?.jellyfin && + Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined)) || + // Compilation filter is only valid when on the artist page + (filter.compilation !== undefined && customFilters?.artistIds)); const isSubsonicFilterApplied = server?.type === ServerType.SUBSONIC && (filter.maxYear || filter.minYear); - const isCompilationFilterApplied = - server?.type === ServerType.NAVIDROME && filter.compilation !== undefined; - return ( isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied || filter.genres?.length || filter.favorite !== undefined || - isCompilationFilterApplied + // If we are on the artist page, the artist id filter should not be active + (filter.artistIds?.length && !(customFilters?.artistIds as any | undefined)?.length) ); }, [ + customFilters?.artistIds, filter?._custom?.jellyfin, filter?._custom?.navidrome, + filter.artistIds?.length, filter.compilation, filter.favorite, filter.genres?.length, diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index 9de6972c..5b81964c 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -1,5 +1,5 @@ import debounce from 'lodash/debounce'; -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; @@ -43,6 +43,10 @@ export const JellyfinAlbumFilters = ({ // TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library const genreListQuery = useGenreList({ + options: { + cacheTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 1, + }, query: { musicFolderId: filter?.musicFolderId, sortBy: GenreListSort.NAME, @@ -61,6 +65,10 @@ export const JellyfinAlbumFilters = ({ }, [genreListQuery.data]); const tagsQuery = useTagList({ + options: { + cacheTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 1, + }, query: { folder: filter?.musicFolderId, type: LibraryItem.ALBUM, @@ -72,24 +80,55 @@ export const JellyfinAlbumFilters = ({ return filter?._custom?.jellyfin?.Tags?.split('|'); }, [filter?._custom?.jellyfin?.Tags]); - const yesNoFilter = [ - { - label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), - onChange: (favorite?: boolean) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: filter?._custom, - favorite, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + const yesNoFilter = useMemo(() => { + const filters = [ + { + label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), + onChange: (favorite?: boolean) => { + const updatedFilters = setFilter({ + customFilters, + data: { + _custom: filter?._custom, + favorite, + }, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + onFilterChange(updatedFilters); + }, + value: filter?.favorite, }, - value: filter?.favorite, - }, - ]; + ]; + + if (customFilters?.artistIds) { + filters.push({ + label: t('filter.isCompilation', { postProcess: 'sentenceCase' }), + onChange: (compilation?: boolean) => { + const updatedFilters = setFilter({ + customFilters, + data: { + _custom: filter._custom, + compilation, + }, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + onFilterChange(updatedFilters); + }, + value: filter.compilation, + }); + } + return filters; + }, [ + customFilters, + filter._custom, + filter.compilation, + filter?.favorite, + onFilterChange, + pageKey, + setFilter, + t, + ]); const handleMinYearFilter = debounce((e: number | string) => { if (typeof e === 'number' && (e < 1700 || e > 2300)) return; @@ -132,8 +171,6 @@ export const JellyfinAlbumFilters = ({ onFilterChange(updatedFilters); }, 250); - const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState(''); - const albumArtistListQuery = useAlbumArtistList({ options: { cacheTime: 1000 * 60 * 2, @@ -161,7 +198,7 @@ export const JellyfinAlbumFilters = ({ customFilters, data: { _custom: filter?._custom, - artistIds: e || undefined, + artistIds: e?.length ? e : undefined, }, itemType: LibraryItem.ALBUM, key: pageKey, @@ -238,16 +275,14 @@ export const JellyfinAlbumFilters = ({ : undefined} searchable - searchValue={albumArtistSearchTerm} /> {tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && ( diff --git a/src/renderer/features/albums/components/navidrome-album-filters.tsx b/src/renderer/features/albums/components/navidrome-album-filters.tsx index cfa30ff1..fe105817 100644 --- a/src/renderer/features/albums/components/navidrome-album-filters.tsx +++ b/src/renderer/features/albums/components/navidrome-album-filters.tsx @@ -1,5 +1,5 @@ import debounce from 'lodash/debounce'; -import { ChangeEvent, useMemo, useState } from 'react'; +import { ChangeEvent, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; @@ -43,6 +43,10 @@ export const NavidromeAlbumFilters = ({ const { setFilter } = useListStoreActions(); const genreListQuery = useGenreList({ + options: { + cacheTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 1, + }, query: { sortBy: GenreListSort.NAME, sortOrder: SortOrder.ASC, @@ -73,6 +77,10 @@ export const NavidromeAlbumFilters = ({ }, 250); const tagsQuery = useTagList({ + options: { + cacheTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 1, + }, query: { type: LibraryItem.ALBUM, }, @@ -177,8 +185,6 @@ export const NavidromeAlbumFilters = ({ onFilterChange(updatedFilters); }, 500); - const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState(''); - const albumArtistListQuery = useAlbumArtistList({ options: { cacheTime: 1000 * 60 * 2, @@ -293,13 +299,12 @@ export const NavidromeAlbumFilters = ({ label={t('entity.artist', { count: 1, postProcess: 'titleCase' })} limit={300} onChange={handleAlbumArtistFilter} - onSearchChange={setAlbumArtistSearchTerm} rightSection={albumArtistListQuery.isFetching ? : undefined} searchable - searchValue={albumArtistSearchTerm} /> {tagsQuery.data?.enumTags?.length && + tagsQuery.data.enumTags.length > 0 && tagsQuery.data.enumTags.map((tag) => ( void; pageKey: string; serverId?: string; } export const SubsonicAlbumFilters = ({ + disableArtistFilter, onFilterChange, pageKey, serverId, @@ -32,8 +38,46 @@ export const SubsonicAlbumFilters = ({ const { t } = useTranslation(); const { filter } = useListStoreByKey({ key: pageKey }); const { setFilter } = useListStoreActions(); + const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState(''); + + const albumArtistListQuery = useAlbumArtistList({ + options: { + cacheTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 1, + }, + query: { + sortBy: AlbumArtistListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId, + }); + + const selectableAlbumArtists = useMemo(() => { + if (!albumArtistListQuery?.data?.items) return []; + + return albumArtistListQuery?.data?.items?.map((artist) => ({ + label: artist.name, + value: artist.id, + })); + }, [albumArtistListQuery?.data?.items]); + + const handleAlbumArtistFilter = (e: null | string[]) => { + const updatedFilters = setFilter({ + data: { + artistIds: e?.length ? e : undefined, + }, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + onFilterChange(updatedFilters); + }; const genreListQuery = useGenreList({ + options: { + cacheTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 1, + }, query: { sortBy: GenreListSort.NAME, sortOrder: SortOrder.ASC, @@ -147,6 +191,22 @@ export const SubsonicAlbumFilters = ({ searchable /> + + : undefined} + searchable + searchValue={albumArtistSearchTerm} + /> + ); }; diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index 1ac730bf..fd2301b7 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -167,6 +167,7 @@ export const NavidromeSongFilters = ({ )} {tagsQuery.data?.enumTags?.length && + tagsQuery.data.enumTags.length > 0 && tagsQuery.data.enumTags.map((tag) => (