use tags list for Navidrome genres to support counts

This commit is contained in:
jeffvli
2025-12-02 18:37:31 -08:00
parent 854a26e3f4
commit f84506ce01
11 changed files with 100 additions and 23 deletions
@@ -853,7 +853,7 @@ export const JellyfinController: InternalControllerEndpoint = {
return url; return url;
}, },
getTags: async (args) => { getTagList: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) { if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
+3 -2
View File
@@ -140,11 +140,12 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
getTags: { getTagList: {
method: 'GET', method: 'GET',
path: 'tag', path: 'tag',
query: ndType._parameters.tagList,
responses: { responses: {
200: resultWithHeaders(ndType._response.tags), 200: resultWithHeaders(ndType._response.tagList),
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
@@ -18,6 +18,7 @@ import {
Song, Song,
songListSortMap, songListSortMap,
sortOrderMap, sortOrderMap,
tagListSortMap,
userListSortMap, userListSortMap,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types'; import { ServerFeature } from '/@/shared/types/features-types';
@@ -395,6 +396,40 @@ export const NavidromeController: InternalControllerEndpoint = {
getGenreList: async (args) => { getGenreList: async (args) => {
const { apiClientProps, query } = 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({ const res = await ndApiClient(apiClientProps).getGenreList({
query: { query: {
_end: query.startIndex + (query.limit || 0), _end: query.startIndex + (query.limit || 0),
@@ -637,14 +672,16 @@ export const NavidromeController: InternalControllerEndpoint = {
}).then((result) => result!.totalRecordCount!), }).then((result) => result!.totalRecordCount!),
getStreamUrl: SubsonicController.getStreamUrl, getStreamUrl: SubsonicController.getStreamUrl,
getStructuredLyrics: SubsonicController.getStructuredLyrics, getStructuredLyrics: SubsonicController.getStructuredLyrics,
getTags: async (args) => { getTagList: async (args) => {
const { apiClientProps } = args; const { apiClientProps } = args;
if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) { if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
return { boolTags: undefined, enumTags: undefined, excluded: { album: [], song: [] } }; 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) { if (res.status !== 200) {
throw new Error('failed to get tags'); throw new Error('failed to get tags');
@@ -67,7 +67,7 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
}, [genreListQuery.data]); }, [genreListQuery.data]);
const tagsQuery = useQuery( const tagsQuery = useQuery(
sharedQueries.tags({ sharedQueries.tagList({
options: { options: {
gcTime: 1000 * 60 * 2, gcTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
@@ -243,7 +243,7 @@ const TagFilters = () => {
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
const tagsQuery = useSuspenseQuery( const tagsQuery = useSuspenseQuery(
sharedQueries.tags({ sharedQueries.tagList({
options: { options: {
gcTime: 1000 * 60 * 60, gcTime: 1000 * 60 * 60,
staleTime: 1000 * 60 * 60, staleTime: 1000 * 60 * 60,
@@ -3,7 +3,7 @@ import { queryOptions } from '@tanstack/react-query';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { QueryHookArgs } from '/@/renderer/lib/react-query'; 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 = { export const sharedQueries = {
musicFolders: (args: QueryHookArgs<MusicFolderListQuery>) => { musicFolders: (args: QueryHookArgs<MusicFolderListQuery>) => {
@@ -28,11 +28,11 @@ export const sharedQueries = {
...args.options, ...args.options,
}); });
}, },
tags: (args: QueryHookArgs<TagQuery>) => { tagList: (args: QueryHookArgs<TagListQuery>) => {
return queryOptions({ return queryOptions({
gcTime: 1000 * 60, gcTime: 1000 * 60,
queryFn: ({ signal }) => { queryFn: ({ signal }) => {
return api.controller.getTags({ return api.controller.getTagList({
apiClientProps: { serverId: args.serverId, signal }, apiClientProps: { serverId: args.serverId, signal },
query: args.query, query: args.query,
}); });
@@ -38,7 +38,7 @@ export const JellyfinSongFilters = () => {
}, [genreListQuery.data]); }, [genreListQuery.data]);
const tagsQuery = useQuery( const tagsQuery = useQuery(
sharedQueries.tags({ sharedQueries.tagList({
query: { query: {
type: LibraryItem.SONG, type: LibraryItem.SONG,
}, },
@@ -151,7 +151,7 @@ const TagFilters = () => {
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
const tagsQuery = useSuspenseQuery( const tagsQuery = useSuspenseQuery(
sharedQueries.tags({ sharedQueries.tagList({
query: { type: LibraryItem.SONG }, query: { type: LibraryItem.SONG },
serverId, serverId,
}), }),
@@ -442,18 +442,18 @@ const normalizePlaylist = (
}; };
const normalizeGenre = ( const normalizeGenre = (
item: z.infer<typeof ndType._response.genre>, item: z.infer<typeof ndType._response.genre> & { albumCount?: number; songCount?: number },
server: null | ServerListItem, server: null | ServerListItem,
): Genre => { ): Genre => {
return { return {
_itemType: LibraryItem.GENRE, _itemType: LibraryItem.GENRE,
_serverId: server?.id || 'unknown', _serverId: server?.id || 'unknown',
_serverType: ServerType.NAVIDROME, _serverType: ServerType.NAVIDROME,
albumCount: null, albumCount: item.albumCount ?? null,
id: item.id, id: item.id,
imageUrl: null, imageUrl: null,
name: item.name, name: item.name,
songCount: null, songCount: item.songCount ?? null,
}; };
}; };
+17 -2
View File
@@ -235,6 +235,8 @@ const paginationParameters = z.object({
_start: z.number().optional(), _start: z.number().optional(),
}); });
const optionalPaginationParameters = paginationParameters.partial();
const authenticate = z.object({ const authenticate = z.object({
id: z.string(), id: z.string(),
isAdmin: z.boolean(), isAdmin: z.boolean(),
@@ -578,7 +580,18 @@ const tag = z.object({
tagValue: z.string(), 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 = { export const ndType = {
_enum: { _enum: {
@@ -587,6 +600,7 @@ export const ndType = {
genreList: genreListSort, genreList: genreListSort,
playlistList: NDPlaylistListSort, playlistList: NDPlaylistListSort,
songList: NDSongListSort, songList: NDSongListSort,
tagList: NDTagListSort,
userList: ndUserListSort, userList: ndUserListSort,
}, },
_parameters: { _parameters: {
@@ -601,6 +615,7 @@ export const ndType = {
removeFromPlaylist: removeFromPlaylistParameters, removeFromPlaylist: removeFromPlaylistParameters,
shareItem: shareItemParameters, shareItem: shareItemParameters,
songList: songListParameters, songList: songListParameters,
tagList: tagListParameters,
updatePlaylist: updatePlaylistParameters, updatePlaylist: updatePlaylistParameters,
userList: userListParameters, userList: userListParameters,
}, },
@@ -625,7 +640,7 @@ export const ndType = {
shareItem, shareItem,
song, song,
songList, songList,
tags, tagList,
updatePlaylist, updatePlaylist,
user, user,
userList, userList,
+30 -6
View File
@@ -14,6 +14,7 @@ import {
NDPlaylistListSort, NDPlaylistListSort,
NDSongListSort, NDSongListSort,
NDSortOrder, NDSortOrder,
NDTagListSort,
NDUserListSort, NDUserListSort,
} from '/@/shared/api/navidrome/navidrome-types'; } from '/@/shared/api/navidrome/navidrome-types';
import { ServerFeatures } from '/@/shared/types/features-types'; import { ServerFeatures } from '/@/shared/types/features-types';
@@ -159,6 +160,10 @@ export enum ImageType {
SCREENSHOT = 'SCREENSHOT', SCREENSHOT = 'SCREENSHOT',
} }
export enum TagListSort {
TAG_VALUE = 'tagValue',
}
export type Album = { export type Album = {
_itemType: LibraryItem.ALBUM; _itemType: LibraryItem.ALBUM;
_serverId: string; _serverId: string;
@@ -399,6 +404,24 @@ export const genreListSortMap: GenreListSortMap = {
}, },
}; };
type TagListSortMap = {
jellyfin: Record<TagListSort, undefined>;
navidrome: Record<TagListSort, NDTagListSort | undefined>;
subsonic: Record<TagListSort, undefined>;
};
export const tagListSortMap: TagListSortMap = {
jellyfin: {
tagValue: undefined,
},
navidrome: {
tagValue: NDTagListSort.TAG_VALUE,
},
subsonic: {
tagValue: undefined,
},
};
export enum AlbumListSort { export enum AlbumListSort {
ALBUM_ARTIST = 'albumArtist', ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist', ARTIST = 'artist',
@@ -1224,7 +1247,7 @@ export type ControllerEndpoint = {
getSongListCount: (args: SongListCountArgs) => Promise<number>; getSongListCount: (args: SongListCountArgs) => Promise<number>;
getStreamUrl: (args: StreamArgs) => string; getStreamUrl: (args: StreamArgs) => string;
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>; getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
getTags?: (args: TagArgs) => Promise<TagsResponse>; getTagList?: (args: TagListArgs) => Promise<TagListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>; getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList?: (args: UserListArgs) => Promise<UserListResponse>; getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>; movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
@@ -1316,7 +1339,7 @@ export type InternalControllerEndpoint = {
getStructuredLyrics?: ( getStructuredLyrics?: (
args: ReplaceApiClientProps<StructuredLyricsArgs>, args: ReplaceApiClientProps<StructuredLyricsArgs>,
) => Promise<StructuredLyric[]>; ) => Promise<StructuredLyric[]>;
getTags?: (args: ReplaceApiClientProps<TagArgs>) => Promise<TagsResponse>; getTagList?: (args: ReplaceApiClientProps<TagListArgs>) => Promise<TagListResponse>;
getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>; getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;
getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>; getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>; movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
@@ -1413,16 +1436,17 @@ export type Tag = {
options: { id: string; name: string }[]; options: { id: string; name: string }[];
}; };
export type TagArgs = BaseEndpointArgs & { export type TagListArgs = BaseEndpointArgs & {
query: TagQuery; query: TagListQuery;
}; };
export type TagQuery = { export type TagListQuery = {
folder?: string; folder?: string;
tagName?: string;
type: LibraryItem.ALBUM | LibraryItem.SONG; type: LibraryItem.ALBUM | LibraryItem.SONG;
}; };
export type TagsResponse = { export type TagListResponse = {
boolTags?: string[]; boolTags?: string[];
enumTags?: { name: string; options: { id: string; name: string }[] }[]; enumTags?: { name: string; options: { id: string; name: string }[] }[];
excluded: { excluded: {