From f84506ce01d929c4132cee700df20a25bc80322a Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 2 Dec 2025 18:37:31 -0800 Subject: [PATCH] use tags list for Navidrome genres to support counts --- .../api/jellyfin/jellyfin-controller.ts | 2 +- src/renderer/api/navidrome/navidrome-api.ts | 5 ++- .../api/navidrome/navidrome-controller.ts | 41 ++++++++++++++++++- .../components/jellyfin-album-filters.tsx | 2 +- .../components/navidrome-album-filters.tsx | 2 +- .../features/shared/api/shared-api.ts | 6 +-- .../components/jellyfin-song-filters.tsx | 2 +- .../components/navidrome-song-filters.tsx | 2 +- .../api/navidrome/navidrome-normalize.ts | 6 +-- src/shared/api/navidrome/navidrome-types.ts | 19 ++++++++- src/shared/types/domain-types.ts | 36 +++++++++++++--- 11 files changed, 100 insertions(+), 23 deletions(-) diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index f4b59aab0..24571c4be 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -853,7 +853,7 @@ export const JellyfinController: InternalControllerEndpoint = { return url; }, - getTags: async (args) => { + getTagList: async (args) => { const { apiClientProps, query } = args; if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) { diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index 4465805f0..3120952a7 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -140,11 +140,12 @@ export const contract = c.router({ 500: resultWithHeaders(ndType._response.error), }, }, - getTags: { + getTagList: { method: 'GET', path: 'tag', + query: ndType._parameters.tagList, responses: { - 200: resultWithHeaders(ndType._response.tags), + 200: resultWithHeaders(ndType._response.tagList), 500: resultWithHeaders(ndType._response.error), }, }, diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 985eca474..b0a1fb93d 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -18,6 +18,7 @@ import { Song, songListSortMap, sortOrderMap, + tagListSortMap, userListSortMap, } from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; @@ -395,6 +396,40 @@ export const NavidromeController: InternalControllerEndpoint = { getGenreList: async (args) => { const { apiClientProps, query } = args; + if (hasFeature(apiClientProps.server, ServerFeature.BFR)) { + const res = await ndApiClient(apiClientProps).getTagList({ + query: { + _end: query.startIndex + (query.limit || 0), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: tagListSortMap.navidrome[query.sortBy], + _start: query.startIndex, + library_id: getLibraryId(query.musicFolderId), + tag_name: 'genre', + tag_value: query.searchTerm, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get genre list'); + } + + return { + items: res.body.data.map((genre) => + ndNormalize.genre( + { + albumCount: genre.albumCount, + id: genre.id, + name: genre.tagValue, + songCount: genre.songCount, + }, + apiClientProps.server, + ), + ), + startIndex: query.startIndex || 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; + } + const res = await ndApiClient(apiClientProps).getGenreList({ query: { _end: query.startIndex + (query.limit || 0), @@ -637,14 +672,16 @@ export const NavidromeController: InternalControllerEndpoint = { }).then((result) => result!.totalRecordCount!), getStreamUrl: SubsonicController.getStreamUrl, getStructuredLyrics: SubsonicController.getStructuredLyrics, - getTags: async (args) => { + getTagList: async (args) => { const { apiClientProps } = args; if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) { return { boolTags: undefined, enumTags: undefined, excluded: { album: [], song: [] } }; } - const res = await ndApiClient(apiClientProps).getTags(); + const res = await ndApiClient(apiClientProps).getTagList({ + query: {}, + }); if (res.status !== 200) { throw new Error('failed to get tags'); diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index 9369f843a..b0a437117 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -67,7 +67,7 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte }, [genreListQuery.data]); const tagsQuery = useQuery( - sharedQueries.tags({ + sharedQueries.tagList({ options: { gcTime: 1000 * 60 * 2, staleTime: 1000 * 60 * 1, diff --git a/src/renderer/features/albums/components/navidrome-album-filters.tsx b/src/renderer/features/albums/components/navidrome-album-filters.tsx index 07a243764..a07d3cebc 100644 --- a/src/renderer/features/albums/components/navidrome-album-filters.tsx +++ b/src/renderer/features/albums/components/navidrome-album-filters.tsx @@ -243,7 +243,7 @@ const TagFilters = () => { const serverId = useCurrentServerId(); const tagsQuery = useSuspenseQuery( - sharedQueries.tags({ + sharedQueries.tagList({ options: { gcTime: 1000 * 60 * 60, staleTime: 1000 * 60 * 60, diff --git a/src/renderer/features/shared/api/shared-api.ts b/src/renderer/features/shared/api/shared-api.ts index 4193737d5..ffb50b3d6 100644 --- a/src/renderer/features/shared/api/shared-api.ts +++ b/src/renderer/features/shared/api/shared-api.ts @@ -3,7 +3,7 @@ import { queryOptions } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { MusicFolderListQuery, TagQuery, UserListQuery } from '/@/shared/types/domain-types'; +import { MusicFolderListQuery, TagListQuery, UserListQuery } from '/@/shared/types/domain-types'; export const sharedQueries = { musicFolders: (args: QueryHookArgs) => { @@ -28,11 +28,11 @@ export const sharedQueries = { ...args.options, }); }, - tags: (args: QueryHookArgs) => { + tagList: (args: QueryHookArgs) => { return queryOptions({ gcTime: 1000 * 60, queryFn: ({ signal }) => { - return api.controller.getTags({ + return api.controller.getTagList({ apiClientProps: { serverId: args.serverId, signal }, query: args.query, }); diff --git a/src/renderer/features/songs/components/jellyfin-song-filters.tsx b/src/renderer/features/songs/components/jellyfin-song-filters.tsx index 448c89fa7..54258f825 100644 --- a/src/renderer/features/songs/components/jellyfin-song-filters.tsx +++ b/src/renderer/features/songs/components/jellyfin-song-filters.tsx @@ -38,7 +38,7 @@ export const JellyfinSongFilters = () => { }, [genreListQuery.data]); const tagsQuery = useQuery( - sharedQueries.tags({ + sharedQueries.tagList({ query: { type: LibraryItem.SONG, }, diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index b4160f1a4..bf97553cf 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -151,7 +151,7 @@ const TagFilters = () => { const serverId = useCurrentServerId(); const tagsQuery = useSuspenseQuery( - sharedQueries.tags({ + sharedQueries.tagList({ query: { type: LibraryItem.SONG }, serverId, }), diff --git a/src/shared/api/navidrome/navidrome-normalize.ts b/src/shared/api/navidrome/navidrome-normalize.ts index faf84a817..cd73707a1 100644 --- a/src/shared/api/navidrome/navidrome-normalize.ts +++ b/src/shared/api/navidrome/navidrome-normalize.ts @@ -442,18 +442,18 @@ const normalizePlaylist = ( }; const normalizeGenre = ( - item: z.infer, + item: z.infer & { albumCount?: number; songCount?: number }, server: null | ServerListItem, ): Genre => { return { _itemType: LibraryItem.GENRE, _serverId: server?.id || 'unknown', _serverType: ServerType.NAVIDROME, - albumCount: null, + albumCount: item.albumCount ?? null, id: item.id, imageUrl: null, name: item.name, - songCount: null, + songCount: item.songCount ?? null, }; }; diff --git a/src/shared/api/navidrome/navidrome-types.ts b/src/shared/api/navidrome/navidrome-types.ts index 53d66de4c..485e72704 100644 --- a/src/shared/api/navidrome/navidrome-types.ts +++ b/src/shared/api/navidrome/navidrome-types.ts @@ -235,6 +235,8 @@ const paginationParameters = z.object({ _start: z.number().optional(), }); +const optionalPaginationParameters = paginationParameters.partial(); + const authenticate = z.object({ id: z.string(), isAdmin: z.boolean(), @@ -578,7 +580,18 @@ const tag = z.object({ tagValue: z.string(), }); -const tags = z.array(tag); +const tagList = z.array(tag); + +export enum NDTagListSort { + TAG_VALUE = 'tagValue', +} + +const tagListParameters = optionalPaginationParameters.extend({ + _sort: z.nativeEnum(NDTagListSort).optional(), + library_id: z.array(z.string()).optional(), + tag_name: z.string().optional(), + tag_value: z.string().optional(), // Search +}); export const ndType = { _enum: { @@ -587,6 +600,7 @@ export const ndType = { genreList: genreListSort, playlistList: NDPlaylistListSort, songList: NDSongListSort, + tagList: NDTagListSort, userList: ndUserListSort, }, _parameters: { @@ -601,6 +615,7 @@ export const ndType = { removeFromPlaylist: removeFromPlaylistParameters, shareItem: shareItemParameters, songList: songListParameters, + tagList: tagListParameters, updatePlaylist: updatePlaylistParameters, userList: userListParameters, }, @@ -625,7 +640,7 @@ export const ndType = { shareItem, song, songList, - tags, + tagList, updatePlaylist, user, userList, diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 080be8348..9b689e82d 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -14,6 +14,7 @@ import { NDPlaylistListSort, NDSongListSort, NDSortOrder, + NDTagListSort, NDUserListSort, } from '/@/shared/api/navidrome/navidrome-types'; import { ServerFeatures } from '/@/shared/types/features-types'; @@ -159,6 +160,10 @@ export enum ImageType { SCREENSHOT = 'SCREENSHOT', } +export enum TagListSort { + TAG_VALUE = 'tagValue', +} + export type Album = { _itemType: LibraryItem.ALBUM; _serverId: string; @@ -399,6 +404,24 @@ export const genreListSortMap: GenreListSortMap = { }, }; +type TagListSortMap = { + jellyfin: Record; + navidrome: Record; + subsonic: Record; +}; + +export const tagListSortMap: TagListSortMap = { + jellyfin: { + tagValue: undefined, + }, + navidrome: { + tagValue: NDTagListSort.TAG_VALUE, + }, + subsonic: { + tagValue: undefined, + }, +}; + export enum AlbumListSort { ALBUM_ARTIST = 'albumArtist', ARTIST = 'artist', @@ -1224,7 +1247,7 @@ export type ControllerEndpoint = { getSongListCount: (args: SongListCountArgs) => Promise; getStreamUrl: (args: StreamArgs) => string; getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise; - getTags?: (args: TagArgs) => Promise; + getTagList?: (args: TagListArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; getUserList?: (args: UserListArgs) => Promise; movePlaylistItem?: (args: MoveItemArgs) => Promise; @@ -1316,7 +1339,7 @@ export type InternalControllerEndpoint = { getStructuredLyrics?: ( args: ReplaceApiClientProps, ) => Promise; - getTags?: (args: ReplaceApiClientProps) => Promise; + getTagList?: (args: ReplaceApiClientProps) => Promise; getTopSongs: (args: ReplaceApiClientProps) => Promise; getUserList?: (args: ReplaceApiClientProps) => Promise; movePlaylistItem?: (args: ReplaceApiClientProps) => Promise; @@ -1413,16 +1436,17 @@ export type Tag = { options: { id: string; name: string }[]; }; -export type TagArgs = BaseEndpointArgs & { - query: TagQuery; +export type TagListArgs = BaseEndpointArgs & { + query: TagListQuery; }; -export type TagQuery = { +export type TagListQuery = { folder?: string; + tagName?: string; type: LibraryItem.ALBUM | LibraryItem.SONG; }; -export type TagsResponse = { +export type TagListResponse = { boolTags?: string[]; enumTags?: { name: string; options: { id: string; name: string }[] }[]; excluded: {