diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 04f23731..0eb9958e 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -93,6 +93,9 @@ export const controller: GeneralController = { getAlbumDetail(args) { return apiController('getAlbumDetail', args.apiClientProps.server?.type)?.(args); }, + getAlbumInfo(args) { + return apiController('getAlbumInfo', args.apiClientProps.server?.type)?.(args); + }, getAlbumList(args) { return apiController('getAlbumList', args.apiClientProps.server?.type)?.(args); }, @@ -117,9 +120,6 @@ export const controller: GeneralController = { getMusicFolderList(args) { return apiController('getMusicFolderList', args.apiClientProps.server?.type)?.(args); }, - getAlbumInfo(args) { - return apiController('getAlbumInfo', args.apiClientProps.server?.type)?.(args); - }, getPlaylistDetail(args) { return apiController('getPlaylistDetail', args.apiClientProps.server?.type)?.(args); }, @@ -156,6 +156,9 @@ export const controller: GeneralController = { getStructuredLyrics(args) { return apiController('getStructuredLyrics', args.apiClientProps.server?.type)?.(args); }, + getTags(args) { + return apiController('getTags', args.apiClientProps.server?.type)?.(args); + }, getTopSongs(args) { return apiController('getTopSongs', args.apiClientProps.server?.type)?.(args); }, diff --git a/src/renderer/api/features-types.ts b/src/renderer/api/features-types.ts index a7997d92..872deafc 100644 --- a/src/renderer/api/features-types.ts +++ b/src/renderer/api/features-types.ts @@ -7,6 +7,7 @@ export enum ServerFeature { PLAYLISTS_SMART = 'playlistsSmart', PUBLIC_PLAYLIST = 'publicPlaylist', SHARING_ALBUM_SONG = 'sharingAlbumSong', + TAGS = 'tags', } -export type ServerFeatures = Partial>; +export type ServerFeatures = Partial>; diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 72bb21c8..acb50b51 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -104,6 +104,15 @@ export const contract = c.router({ 400: jfType._response.error, }, }, + getFilterList: { + method: 'GET', + path: 'items/filters', + query: jfType._parameters.filterList, + responses: { + 200: jfType._response.filters, + 400: jfType._response.error, + }, + }, getGenreList: { method: 'GET', path: 'genres', diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 9fe65dc2..be1136cd 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -8,6 +8,7 @@ import { Song, Played, ControllerEndpoint, + LibraryItem, } from '/@/renderer/api/types'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfNormalize } from './jellyfin-normalize'; @@ -15,7 +16,7 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types'; import { z } from 'zod'; import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types'; import { ServerFeature } from '/@/renderer/api/features-types'; -import { VersionInfo, getFeatures } from '/@/renderer/api/utils'; +import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils'; import chunk from 'lodash/chunk'; const formatCommaDelimitedString = (value: string[]) => { @@ -35,6 +36,7 @@ const VERSION_INFO: VersionInfo = [ [ServerFeature.PUBLIC_PLAYLIST]: [1], }, ], + ['10.0.0', { [ServerFeature.TAGS]: [1] }], ]; export const JellyfinController: ControllerEndpoint = { @@ -803,6 +805,31 @@ export const JellyfinController: ControllerEndpoint = { apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), + getTags: async (args) => { + const { apiClientProps, query } = args; + + if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) { + return { boolTags: undefined, enumTags: undefined }; + } + + const res = await jfApiClient(apiClientProps).getFilterList({ + query: { + IncludeItemTypes: query.type === LibraryItem.SONG ? 'Audio' : 'MusicAlbum', + ParentId: query.folder, + UserId: apiClientProps.server?.userId ?? '', + }, + }); + + if (res.status !== 200) { + throw new Error('failed to get tags'); + } + + return { + boolTags: res.body.Tags?.sort((a, b) => + a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()), + ), + }; + }, getTopSongs: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/jellyfin/jellyfin-types.ts b/src/renderer/api/jellyfin/jellyfin-types.ts index 1ace9416..4b3e80f4 100644 --- a/src/renderer/api/jellyfin/jellyfin-types.ts +++ b/src/renderer/api/jellyfin/jellyfin-types.ts @@ -697,6 +697,18 @@ export enum JellyfinExtensions { const moveItem = z.null(); +const filterListParameters = z.object({ + IncludeItemTypes: z.string().optional(), + ParentId: z.string().optional(), + UserId: z.string().optional(), +}); + +const filters = z.object({ + Genres: z.string().array().optional(), + Tags: z.string().array().optional(), + Years: z.number().array().optional(), +}); + export const jfType = { _enum: { albumArtistList: albumArtistListSort, @@ -718,6 +730,7 @@ export const jfType = { createPlaylist: createPlaylistParameters, deletePlaylist: deletePlaylistParameters, favorite: favoriteParameters, + filterList: filterListParameters, genreList: genreListParameters, musicFolderList: musicFolderListParameters, playlistDetail: playlistDetailParameters, @@ -742,6 +755,7 @@ export const jfType = { deletePlaylist, error, favorite, + filters, genre, genreList, lyrics, diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index 251d5bb9..8e5e7529 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -138,6 +138,14 @@ export const contract = c.router({ 500: resultWithHeaders(ndType._response.error), }, }, + getTags: { + method: 'GET', + path: 'tag', + responses: { + 200: resultWithHeaders(ndType._response.tags), + 500: resultWithHeaders(ndType._response.error), + }, + }, getUserList: { method: 'GET', path: 'user', diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index b3887e4b..2d50be8c 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -46,6 +46,8 @@ const NAVIDROME_ROLES: Array = [ 'remixer', ]; +const EXCLUDED_TAGS = new Set(['disctotal', 'genre', 'tracktotal']); + const excludeMissing = (server: ServerListItem | null) => { if (hasFeature(server, ServerFeature.BFR)) { return { missing: false }; @@ -484,11 +486,12 @@ export const NavidromeController: ControllerEndpoint = { } const features: ServerFeatures = { - bfr: !!navidromeFeatures[ServerFeature.BFR], - lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS], - playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART], - publicPlaylist: true, - sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG], + bfr: navidromeFeatures[ServerFeature.BFR], + lyricsMultipleStructured: navidromeFeatures[SubsonicExtensions.SONG_LYRICS], + playlistsSmart: navidromeFeatures[ServerFeature.PLAYLISTS_SMART], + publicPlaylist: [1], + sharingAlbumSong: navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG], + tags: navidromeFeatures[ServerFeature.BFR], }; return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! }; @@ -597,6 +600,45 @@ export const NavidromeController: ControllerEndpoint = { query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), getStructuredLyrics: SubsonicController.getStructuredLyrics, + getTags: async (args) => { + const { apiClientProps } = args; + + if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) { + return { boolTags: undefined, enumTags: undefined }; + } + + const res = await ndApiClient(apiClientProps).getTags(); + + if (res.status !== 200) { + throw new Error('failed to get tags'); + } + + const tagsToValues = new Map(); + + for (const tag of res.body.data) { + if (!EXCLUDED_TAGS.has(tag.tagName)) { + if (tagsToValues.has(tag.tagName)) { + tagsToValues.get(tag.tagName)!.push(tag.tagValue); + } else { + tagsToValues.set(tag.tagName, [tag.tagValue]); + } + } + } + + return { + boolTags: undefined, + enumTags: Array.from(tagsToValues) + .map((data) => ({ + name: data[0], + options: data[1].sort((a, b) => + a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()), + ), + })) + .sort((a, b) => + a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()), + ), + }; + }, getTopSongs: SubsonicController.getTopSongs, getTranscodingUrl: SubsonicController.getTranscodingUrl, getUserList: async (args) => { diff --git a/src/renderer/api/navidrome/navidrome-types.ts b/src/renderer/api/navidrome/navidrome-types.ts index 3607a2ac..253aa9a8 100644 --- a/src/renderer/api/navidrome/navidrome-types.ts +++ b/src/renderer/api/navidrome/navidrome-types.ts @@ -338,6 +338,16 @@ const moveItemParameters = z.object({ const moveItem = z.null(); +const tag = z.object({ + albumCount: z.number().optional(), + id: z.string(), + songCount: z.number().optional(), + tagName: z.string(), + tagValue: z.string(), +}); + +const tags = z.array(tag); + export const ndType = { _enum: { albumArtistList: NDAlbumArtistListSort, @@ -383,6 +393,7 @@ export const ndType = { shareItem, song, songList, + tags, updatePlaylist, user, userList, diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 3c1ee0cd..db453566 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -294,6 +294,9 @@ export const queryKeys: Record< return [serverId, 'song', 'similar'] as const; }, }, + tags: { + list: (serverId: string, type: string) => [serverId, 'tags', type] as const, + }, users: { list: (serverId: string, query?: UserListQuery) => { if (query) return [serverId, 'users', 'list', query] as const; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 8a26c258..b63698b8 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -529,7 +529,6 @@ export const SubsonicController: ControllerEndpoint = { } let artists = (res.body.artists?.index || []).flatMap((index) => index.artist); - console.log(artists.length); if (query.role) { artists = artists.filter( (artist) => !artist.roles || artist.roles.includes(query.role!), @@ -811,7 +810,7 @@ export const SubsonicController: ControllerEndpoint = { } if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) { - features.lyricsMultipleStructured = true; + features.lyricsMultipleStructured = [1]; } return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index dff50044..a24abd9e 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1239,6 +1239,25 @@ export type TranscodingArgs = { query: TranscodingQuery; } & BaseEndpointArgs; +export type TagQuery = { + folder?: string; + type: LibraryItem.ALBUM | LibraryItem.SONG; +}; + +export type TagArgs = { + query: TagQuery; +} & BaseEndpointArgs; + +export type Tag = { + name: string; + options: string[]; +}; + +export type TagResponses = { + boolTags?: string[]; + enumTags?: Tag[]; +}; + export type ControllerEndpoint = { addToPlaylist: (args: AddToPlaylistArgs) => Promise; authenticate: ( @@ -1275,6 +1294,7 @@ export type ControllerEndpoint = { getSongList: (args: SongListArgs) => Promise; getSongListCount: (args: SongListArgs) => Promise; getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise; + getTags?: (args: TagArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; getTranscodingUrl: (args: TranscodingArgs) => string; getUserList?: (args: UserListArgs) => Promise; diff --git a/src/renderer/api/utils.ts b/src/renderer/api/utils.ts index 22c836f6..d995b18b 100644 --- a/src/renderer/api/utils.ts +++ b/src/renderer/api/utils.ts @@ -48,7 +48,7 @@ export const hasFeature = (server: ServerListItem | null, feature: ServerFeature return false; } - return server.features[feature] ?? false; + return (server.features[feature]?.length || 0) > 0; }; export type VersionInfo = ReadonlyArray<[string, Record]>; diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index 5d51258a..24ca9e4f 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -14,6 +14,7 @@ import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; import { useGenreList } from '/@/renderer/features/genres'; import { AlbumListFilter, useListStoreActions } from '/@/renderer/store'; +import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list'; interface JellyfinAlbumFiltersProps { customFilters?: Partial; @@ -53,6 +54,18 @@ export const JellyfinAlbumFilters = ({ })); }, [genreListQuery.data]); + const tagsQuery = useTagList({ + query: { + folder: filter?.musicFolderId, + type: LibraryItem.SONG, + }, + serverId, + }); + + const selectedTags = useMemo(() => { + return filter?._custom?.jellyfin?.Tags?.split('|'); + }, [filter?._custom?.jellyfin?.Tags]); + const toggleFilters = [ { label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), @@ -150,6 +163,24 @@ export const JellyfinAlbumFilters = ({ onFilterChange(updatedFilters); }; + const handleTagFilter = debounce((e: string[] | undefined) => { + const updatedFilters = setFilter({ + customFilters, + data: { + _custom: { + ...filter?._custom, + jellyfin: { + ...filter?._custom?.jellyfin, + Tags: e?.join('|') || undefined, + }, + }, + }, + itemType: LibraryItem.SONG, + key: pageKey, + }) as AlbumListFilter; + onFilterChange(updatedFilters); + }, 250); + return ( {toggleFilters.map((filter) => ( @@ -213,6 +244,19 @@ export const JellyfinAlbumFilters = ({ onSearchChange={setAlbumArtistSearchTerm} /> + {tagsQuery.data?.boolTags?.length && ( + + + + )} ); }; diff --git a/src/renderer/features/albums/components/navidrome-album-filters.tsx b/src/renderer/features/albums/components/navidrome-album-filters.tsx index 77b9a92c..a3c1a156 100644 --- a/src/renderer/features/albums/components/navidrome-album-filters.tsx +++ b/src/renderer/features/albums/components/navidrome-album-filters.tsx @@ -13,6 +13,7 @@ import { SortOrder, } from '/@/renderer/api/types'; import { useTranslation } from 'react-i18next'; +import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list'; interface NavidromeAlbumFiltersProps { customFilters?: Partial; @@ -63,6 +64,13 @@ export const NavidromeAlbumFilters = ({ onFilterChange(updatedFilters); }, 250); + const tagsQuery = useTagList({ + query: { + type: LibraryItem.SONG, + }, + serverId, + }); + const toggleFilters = [ { label: t('filter.isRated', { postProcess: 'sentenceCase' }), @@ -200,6 +208,25 @@ export const NavidromeAlbumFilters = ({ onFilterChange(updatedFilters); }; + const handleTagFilter = debounce((tag: string, e: string | null) => { + const updatedFilters = setFilter({ + customFilters, + data: { + _custom: { + ...filter._custom, + navidrome: { + ...filter._custom?.navidrome, + [tag]: e || undefined, + }, + }, + }, + itemType: LibraryItem.SONG, + key: pageKey, + }) as AlbumListFilter; + + onFilterChange(updatedFilters); + }, 250); + return ( {toggleFilters.map((filter) => ( @@ -248,6 +275,25 @@ export const NavidromeAlbumFilters = ({ onSearchChange={setAlbumArtistSearchTerm} /> + {tagsQuery.data?.enumTags?.length && + tagsQuery.data.enumTags.map((tag) => ( + + handleTagFilter(tag.name, value)} + /> + + ))} ); }; diff --git a/src/renderer/features/tag/queries/use-tag-list.ts b/src/renderer/features/tag/queries/use-tag-list.ts new file mode 100644 index 00000000..9da4368a --- /dev/null +++ b/src/renderer/features/tag/queries/use-tag-list.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { getServerById } from '/@/renderer/store'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { hasFeature } from '/@/renderer/api/utils'; +import { ServerFeature } from '/@/renderer/api/features-types'; +import { TagQuery } from '/@/renderer/api/types'; + +export const useTagList = (args: QueryHookArgs) => { + const { query, options, serverId } = args || {}; + const server = getServerById(serverId); + + return useQuery({ + enabled: !!server && hasFeature(server, ServerFeature.TAGS), + queryFn: ({ signal }) => { + if (!server) throw new Error('Server not found'); + return api.controller.getTags({ apiClientProps: { server, signal }, query }); + }, + queryKey: queryKeys.tags.list(server?.id || ''), + staleTime: 1000 * 60, + ...options, + }); +};