mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 10:23:33 +00:00
Tag filter support
- Jellyfin: Uses `/items/filters` to get list of boolean tags. Notably, does not use this same filter for genres. Separate filter for song/album - Navidrome: Uses `/api/tags`, which appears to be album-level as multiple independent selects. Same filter for song/album
This commit is contained in:
parent
b0d86ee5c9
commit
e1aa8d74f3
17 changed files with 360 additions and 16 deletions
|
|
@ -93,6 +93,9 @@ export const controller: GeneralController = {
|
||||||
getAlbumDetail(args) {
|
getAlbumDetail(args) {
|
||||||
return apiController('getAlbumDetail', args.apiClientProps.server?.type)?.(args);
|
return apiController('getAlbumDetail', args.apiClientProps.server?.type)?.(args);
|
||||||
},
|
},
|
||||||
|
getAlbumInfo(args) {
|
||||||
|
return apiController('getAlbumInfo', args.apiClientProps.server?.type)?.(args);
|
||||||
|
},
|
||||||
getAlbumList(args) {
|
getAlbumList(args) {
|
||||||
return apiController('getAlbumList', args.apiClientProps.server?.type)?.(args);
|
return apiController('getAlbumList', args.apiClientProps.server?.type)?.(args);
|
||||||
},
|
},
|
||||||
|
|
@ -117,9 +120,6 @@ export const controller: GeneralController = {
|
||||||
getMusicFolderList(args) {
|
getMusicFolderList(args) {
|
||||||
return apiController('getMusicFolderList', args.apiClientProps.server?.type)?.(args);
|
return apiController('getMusicFolderList', args.apiClientProps.server?.type)?.(args);
|
||||||
},
|
},
|
||||||
getAlbumInfo(args) {
|
|
||||||
return apiController('getAlbumInfo', args.apiClientProps.server?.type)?.(args);
|
|
||||||
},
|
|
||||||
getPlaylistDetail(args) {
|
getPlaylistDetail(args) {
|
||||||
return apiController('getPlaylistDetail', args.apiClientProps.server?.type)?.(args);
|
return apiController('getPlaylistDetail', args.apiClientProps.server?.type)?.(args);
|
||||||
},
|
},
|
||||||
|
|
@ -156,6 +156,9 @@ export const controller: GeneralController = {
|
||||||
getStructuredLyrics(args) {
|
getStructuredLyrics(args) {
|
||||||
return apiController('getStructuredLyrics', args.apiClientProps.server?.type)?.(args);
|
return apiController('getStructuredLyrics', args.apiClientProps.server?.type)?.(args);
|
||||||
},
|
},
|
||||||
|
getTags(args) {
|
||||||
|
return apiController('getTags', args.apiClientProps.server?.type)?.(args);
|
||||||
|
},
|
||||||
getTopSongs(args) {
|
getTopSongs(args) {
|
||||||
return apiController('getTopSongs', args.apiClientProps.server?.type)?.(args);
|
return apiController('getTopSongs', args.apiClientProps.server?.type)?.(args);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export enum ServerFeature {
|
||||||
PLAYLISTS_SMART = 'playlistsSmart',
|
PLAYLISTS_SMART = 'playlistsSmart',
|
||||||
PUBLIC_PLAYLIST = 'publicPlaylist',
|
PUBLIC_PLAYLIST = 'publicPlaylist',
|
||||||
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
||||||
|
TAGS = 'tags',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerFeatures = Partial<Record<ServerFeature, boolean>>;
|
export type ServerFeatures = Partial<Record<ServerFeature, number[]>>;
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,15 @@ export const contract = c.router({
|
||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getFilterList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'items/filters',
|
||||||
|
query: jfType._parameters.filterList,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.filters,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
getGenreList: {
|
getGenreList: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'genres',
|
path: 'genres',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Song,
|
Song,
|
||||||
Played,
|
Played,
|
||||||
ControllerEndpoint,
|
ControllerEndpoint,
|
||||||
|
LibraryItem,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||||
import { jfNormalize } from './jellyfin-normalize';
|
import { jfNormalize } from './jellyfin-normalize';
|
||||||
|
|
@ -15,7 +16,7 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
||||||
import { ServerFeature } from '/@/renderer/api/features-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';
|
import chunk from 'lodash/chunk';
|
||||||
|
|
||||||
const formatCommaDelimitedString = (value: string[]) => {
|
const formatCommaDelimitedString = (value: string[]) => {
|
||||||
|
|
@ -35,6 +36,7 @@ const VERSION_INFO: VersionInfo = [
|
||||||
[ServerFeature.PUBLIC_PLAYLIST]: [1],
|
[ServerFeature.PUBLIC_PLAYLIST]: [1],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
['10.0.0', { [ServerFeature.TAGS]: [1] }],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const JellyfinController: ControllerEndpoint = {
|
export const JellyfinController: ControllerEndpoint = {
|
||||||
|
|
@ -803,6 +805,31 @@ export const JellyfinController: ControllerEndpoint = {
|
||||||
apiClientProps,
|
apiClientProps,
|
||||||
query: { ...query, limit: 1, startIndex: 0 },
|
query: { ...query, limit: 1, startIndex: 0 },
|
||||||
}).then((result) => result!.totalRecordCount!),
|
}).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) => {
|
getTopSongs: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -697,6 +697,18 @@ export enum JellyfinExtensions {
|
||||||
|
|
||||||
const moveItem = z.null();
|
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 = {
|
export const jfType = {
|
||||||
_enum: {
|
_enum: {
|
||||||
albumArtistList: albumArtistListSort,
|
albumArtistList: albumArtistListSort,
|
||||||
|
|
@ -718,6 +730,7 @@ export const jfType = {
|
||||||
createPlaylist: createPlaylistParameters,
|
createPlaylist: createPlaylistParameters,
|
||||||
deletePlaylist: deletePlaylistParameters,
|
deletePlaylist: deletePlaylistParameters,
|
||||||
favorite: favoriteParameters,
|
favorite: favoriteParameters,
|
||||||
|
filterList: filterListParameters,
|
||||||
genreList: genreListParameters,
|
genreList: genreListParameters,
|
||||||
musicFolderList: musicFolderListParameters,
|
musicFolderList: musicFolderListParameters,
|
||||||
playlistDetail: playlistDetailParameters,
|
playlistDetail: playlistDetailParameters,
|
||||||
|
|
@ -742,6 +755,7 @@ export const jfType = {
|
||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
error,
|
error,
|
||||||
favorite,
|
favorite,
|
||||||
|
filters,
|
||||||
genre,
|
genre,
|
||||||
genreList,
|
genreList,
|
||||||
lyrics,
|
lyrics,
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,14 @@ export const contract = c.router({
|
||||||
500: resultWithHeaders(ndType._response.error),
|
500: resultWithHeaders(ndType._response.error),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getTags: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'tag',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.tags),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
getUserList: {
|
getUserList: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'user',
|
path: 'user',
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ const NAVIDROME_ROLES: Array<string | { label: string; value: string }> = [
|
||||||
'remixer',
|
'remixer',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const EXCLUDED_TAGS = new Set<string>(['disctotal', 'genre', 'tracktotal']);
|
||||||
|
|
||||||
const excludeMissing = (server: ServerListItem | null) => {
|
const excludeMissing = (server: ServerListItem | null) => {
|
||||||
if (hasFeature(server, ServerFeature.BFR)) {
|
if (hasFeature(server, ServerFeature.BFR)) {
|
||||||
return { missing: false };
|
return { missing: false };
|
||||||
|
|
@ -484,11 +486,12 @@ export const NavidromeController: ControllerEndpoint = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const features: ServerFeatures = {
|
const features: ServerFeatures = {
|
||||||
bfr: !!navidromeFeatures[ServerFeature.BFR],
|
bfr: navidromeFeatures[ServerFeature.BFR],
|
||||||
lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
|
lyricsMultipleStructured: navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
|
||||||
playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
|
playlistsSmart: navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
|
||||||
publicPlaylist: true,
|
publicPlaylist: [1],
|
||||||
sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
|
sharingAlbumSong: navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
|
||||||
|
tags: navidromeFeatures[ServerFeature.BFR],
|
||||||
};
|
};
|
||||||
|
|
||||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
|
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
|
||||||
|
|
@ -597,6 +600,45 @@ export const NavidromeController: ControllerEndpoint = {
|
||||||
query: { ...query, limit: 1, startIndex: 0 },
|
query: { ...query, limit: 1, startIndex: 0 },
|
||||||
}).then((result) => result!.totalRecordCount!),
|
}).then((result) => result!.totalRecordCount!),
|
||||||
getStructuredLyrics: SubsonicController.getStructuredLyrics,
|
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<string, string[]>();
|
||||||
|
|
||||||
|
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,
|
getTopSongs: SubsonicController.getTopSongs,
|
||||||
getTranscodingUrl: SubsonicController.getTranscodingUrl,
|
getTranscodingUrl: SubsonicController.getTranscodingUrl,
|
||||||
getUserList: async (args) => {
|
getUserList: async (args) => {
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,16 @@ const moveItemParameters = z.object({
|
||||||
|
|
||||||
const moveItem = z.null();
|
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 = {
|
export const ndType = {
|
||||||
_enum: {
|
_enum: {
|
||||||
albumArtistList: NDAlbumArtistListSort,
|
albumArtistList: NDAlbumArtistListSort,
|
||||||
|
|
@ -383,6 +393,7 @@ export const ndType = {
|
||||||
shareItem,
|
shareItem,
|
||||||
song,
|
song,
|
||||||
songList,
|
songList,
|
||||||
|
tags,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
user,
|
user,
|
||||||
userList,
|
userList,
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,9 @@ export const queryKeys: Record<
|
||||||
return [serverId, 'song', 'similar'] as const;
|
return [serverId, 'song', 'similar'] as const;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
tags: {
|
||||||
|
list: (serverId: string, type: string) => [serverId, 'tags', type] as const,
|
||||||
|
},
|
||||||
users: {
|
users: {
|
||||||
list: (serverId: string, query?: UserListQuery) => {
|
list: (serverId: string, query?: UserListQuery) => {
|
||||||
if (query) return [serverId, 'users', 'list', query] as const;
|
if (query) return [serverId, 'users', 'list', query] as const;
|
||||||
|
|
|
||||||
|
|
@ -529,7 +529,6 @@ export const SubsonicController: ControllerEndpoint = {
|
||||||
}
|
}
|
||||||
|
|
||||||
let artists = (res.body.artists?.index || []).flatMap((index) => index.artist);
|
let artists = (res.body.artists?.index || []).flatMap((index) => index.artist);
|
||||||
console.log(artists.length);
|
|
||||||
if (query.role) {
|
if (query.role) {
|
||||||
artists = artists.filter(
|
artists = artists.filter(
|
||||||
(artist) => !artist.roles || artist.roles.includes(query.role!),
|
(artist) => !artist.roles || artist.roles.includes(query.role!),
|
||||||
|
|
@ -811,7 +810,7 @@ export const SubsonicController: ControllerEndpoint = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
|
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
|
||||||
features.lyricsMultipleStructured = true;
|
features.lyricsMultipleStructured = [1];
|
||||||
}
|
}
|
||||||
|
|
||||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
||||||
|
|
|
||||||
|
|
@ -1239,6 +1239,25 @@ export type TranscodingArgs = {
|
||||||
query: TranscodingQuery;
|
query: TranscodingQuery;
|
||||||
} & BaseEndpointArgs;
|
} & 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 = {
|
export type ControllerEndpoint = {
|
||||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
|
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
|
||||||
authenticate: (
|
authenticate: (
|
||||||
|
|
@ -1275,6 +1294,7 @@ export type ControllerEndpoint = {
|
||||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||||
getSongListCount: (args: SongListArgs) => Promise<number>;
|
getSongListCount: (args: SongListArgs) => Promise<number>;
|
||||||
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
||||||
|
getTags?: (args: TagArgs) => Promise<TagResponses>;
|
||||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||||
getTranscodingUrl: (args: TranscodingArgs) => string;
|
getTranscodingUrl: (args: TranscodingArgs) => string;
|
||||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export const hasFeature = (server: ServerListItem | null, feature: ServerFeature
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.features[feature] ?? false;
|
return (server.features[feature]?.length || 0) > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VersionInfo = ReadonlyArray<[string, Record<string, readonly number[]>]>;
|
export type VersionInfo = ReadonlyArray<[string, Record<string, readonly number[]>]>;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer
|
||||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||||
import { useGenreList } from '/@/renderer/features/genres';
|
import { useGenreList } from '/@/renderer/features/genres';
|
||||||
import { AlbumListFilter, useListStoreActions } from '/@/renderer/store';
|
import { AlbumListFilter, useListStoreActions } from '/@/renderer/store';
|
||||||
|
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
|
||||||
|
|
||||||
interface JellyfinAlbumFiltersProps {
|
interface JellyfinAlbumFiltersProps {
|
||||||
customFilters?: Partial<AlbumListFilter>;
|
customFilters?: Partial<AlbumListFilter>;
|
||||||
|
|
@ -53,6 +54,18 @@ export const JellyfinAlbumFilters = ({
|
||||||
}));
|
}));
|
||||||
}, [genreListQuery.data]);
|
}, [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 = [
|
const toggleFilters = [
|
||||||
{
|
{
|
||||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||||
|
|
@ -150,6 +163,24 @@ export const JellyfinAlbumFilters = ({
|
||||||
onFilterChange(updatedFilters);
|
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 (
|
return (
|
||||||
<Stack p="0.8rem">
|
<Stack p="0.8rem">
|
||||||
{toggleFilters.map((filter) => (
|
{toggleFilters.map((filter) => (
|
||||||
|
|
@ -213,6 +244,19 @@ export const JellyfinAlbumFilters = ({
|
||||||
onSearchChange={setAlbumArtistSearchTerm}
|
onSearchChange={setAlbumArtistSearchTerm}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
{tagsQuery.data?.boolTags?.length && (
|
||||||
|
<Group grow>
|
||||||
|
<MultiSelect
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
data={tagsQuery.data.boolTags}
|
||||||
|
defaultValue={selectedTags}
|
||||||
|
label={t('common.tags', { postProcess: 'sentenceCase' })}
|
||||||
|
width={250}
|
||||||
|
onChange={handleTagFilter}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
SortOrder,
|
SortOrder,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
|
||||||
|
|
||||||
interface NavidromeAlbumFiltersProps {
|
interface NavidromeAlbumFiltersProps {
|
||||||
customFilters?: Partial<AlbumListFilter>;
|
customFilters?: Partial<AlbumListFilter>;
|
||||||
|
|
@ -63,6 +64,13 @@ export const NavidromeAlbumFilters = ({
|
||||||
onFilterChange(updatedFilters);
|
onFilterChange(updatedFilters);
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
|
const tagsQuery = useTagList({
|
||||||
|
query: {
|
||||||
|
type: LibraryItem.SONG,
|
||||||
|
},
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
const toggleFilters = [
|
const toggleFilters = [
|
||||||
{
|
{
|
||||||
label: t('filter.isRated', { postProcess: 'sentenceCase' }),
|
label: t('filter.isRated', { postProcess: 'sentenceCase' }),
|
||||||
|
|
@ -200,6 +208,25 @@ export const NavidromeAlbumFilters = ({
|
||||||
onFilterChange(updatedFilters);
|
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 (
|
return (
|
||||||
<Stack p="0.8rem">
|
<Stack p="0.8rem">
|
||||||
{toggleFilters.map((filter) => (
|
{toggleFilters.map((filter) => (
|
||||||
|
|
@ -248,6 +275,25 @@ export const NavidromeAlbumFilters = ({
|
||||||
onSearchChange={setAlbumArtistSearchTerm}
|
onSearchChange={setAlbumArtistSearchTerm}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
{tagsQuery.data?.enumTags?.length &&
|
||||||
|
tagsQuery.data.enumTags.map((tag) => (
|
||||||
|
<Group
|
||||||
|
key={tag.name}
|
||||||
|
grow
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
data={tag.options}
|
||||||
|
defaultValue={
|
||||||
|
filter._custom?.navidrome?.[tag.name] as string | undefined
|
||||||
|
}
|
||||||
|
label={tag.name}
|
||||||
|
width={150}
|
||||||
|
onChange={(value) => handleTagFilter(tag.name, value)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import { Divider, Group, Stack } from '@mantine/core';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
|
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
|
||||||
import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
|
import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
|
||||||
import { useGenreList } from '/@/renderer/features/genres';
|
|
||||||
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
|
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
|
||||||
|
import { useGenreList } from '/@/renderer/features/genres';
|
||||||
|
|
||||||
interface JellyfinSongFiltersProps {
|
interface JellyfinSongFiltersProps {
|
||||||
customFilters?: Partial<SongListFilter>;
|
customFilters?: Partial<SongListFilter>;
|
||||||
|
|
@ -24,9 +25,10 @@ export const JellyfinSongFilters = ({
|
||||||
const { setFilter } = useListStoreActions();
|
const { setFilter } = useListStoreActions();
|
||||||
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
|
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
|
||||||
|
|
||||||
const isGenrePage = customFilters?._custom?.jellyfin?.GenreIds !== undefined;
|
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||||
|
|
||||||
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
|
// Despite the fact that getTags returns genres, it only returns genre names.
|
||||||
|
// We prefer using IDs, hence the double query
|
||||||
const genreListQuery = useGenreList({
|
const genreListQuery = useGenreList({
|
||||||
query: {
|
query: {
|
||||||
musicFolderId: filter?.musicFolderId,
|
musicFolderId: filter?.musicFolderId,
|
||||||
|
|
@ -45,10 +47,22 @@ export const JellyfinSongFilters = ({
|
||||||
}));
|
}));
|
||||||
}, [genreListQuery.data]);
|
}, [genreListQuery.data]);
|
||||||
|
|
||||||
|
const tagsQuery = useTagList({
|
||||||
|
query: {
|
||||||
|
folder: filter?.musicFolderId,
|
||||||
|
type: LibraryItem.SONG,
|
||||||
|
},
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
const selectedGenres = useMemo(() => {
|
const selectedGenres = useMemo(() => {
|
||||||
return filter?._custom?.jellyfin?.GenreIds?.split(',');
|
return filter?._custom?.jellyfin?.GenreIds?.split(',');
|
||||||
}, [filter?._custom?.jellyfin?.GenreIds]);
|
}, [filter?._custom?.jellyfin?.GenreIds]);
|
||||||
|
|
||||||
|
const selectedTags = useMemo(() => {
|
||||||
|
return filter?._custom?.jellyfin?.Tags?.split('|');
|
||||||
|
}, [filter?._custom?.jellyfin?.Tags]);
|
||||||
|
|
||||||
const toggleFilters = [
|
const toggleFilters = [
|
||||||
{
|
{
|
||||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||||
|
|
@ -133,6 +147,25 @@ export const JellyfinSongFilters = ({
|
||||||
onFilterChange(updatedFilters);
|
onFilterChange(updatedFilters);
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
|
const handleTagFilter = debounce((e: string[] | undefined) => {
|
||||||
|
const updatedFilters = setFilter({
|
||||||
|
customFilters,
|
||||||
|
data: {
|
||||||
|
_custom: {
|
||||||
|
...filter?._custom,
|
||||||
|
jellyfin: {
|
||||||
|
...filter?._custom?.jellyfin,
|
||||||
|
IncludeItemTypes: 'Audio',
|
||||||
|
Tags: e?.join('|') || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
itemType: LibraryItem.SONG,
|
||||||
|
key: pageKey,
|
||||||
|
}) as SongListFilter;
|
||||||
|
onFilterChange(updatedFilters);
|
||||||
|
}, 250);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="0.8rem">
|
<Stack p="0.8rem">
|
||||||
{toggleFilters.map((filter) => (
|
{toggleFilters.map((filter) => (
|
||||||
|
|
@ -179,6 +212,19 @@ export const JellyfinSongFilters = ({
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
{tagsQuery.data?.boolTags?.length && (
|
||||||
|
<Group grow>
|
||||||
|
<MultiSelect
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
data={tagsQuery.data.boolTags}
|
||||||
|
defaultValue={selectedTags}
|
||||||
|
label={t('common.tags', { postProcess: 'sentenceCase' })}
|
||||||
|
width={250}
|
||||||
|
onChange={handleTagFilter}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
|
||||||
import { useGenreList } from '/@/renderer/features/genres';
|
import { useGenreList } from '/@/renderer/features/genres';
|
||||||
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
|
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
|
||||||
|
|
||||||
interface NavidromeSongFiltersProps {
|
interface NavidromeSongFiltersProps {
|
||||||
customFilters?: Partial<SongListFilter>;
|
customFilters?: Partial<SongListFilter>;
|
||||||
|
|
@ -35,6 +36,13 @@ export const NavidromeSongFilters = ({
|
||||||
serverId,
|
serverId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tagsQuery = useTagList({
|
||||||
|
query: {
|
||||||
|
type: LibraryItem.SONG,
|
||||||
|
},
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
const genreList = useMemo(() => {
|
const genreList = useMemo(() => {
|
||||||
if (!genreListQuery?.data) return [];
|
if (!genreListQuery?.data) return [];
|
||||||
return genreListQuery.data.items.map((genre) => ({
|
return genreListQuery.data.items.map((genre) => ({
|
||||||
|
|
@ -57,6 +65,25 @@ export const NavidromeSongFilters = ({
|
||||||
onFilterChange(updatedFilters);
|
onFilterChange(updatedFilters);
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
|
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 SongListFilter;
|
||||||
|
|
||||||
|
onFilterChange(updatedFilters);
|
||||||
|
}, 250);
|
||||||
|
|
||||||
const toggleFilters = [
|
const toggleFilters = [
|
||||||
{
|
{
|
||||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||||
|
|
@ -84,6 +111,7 @@ export const NavidromeSongFilters = ({
|
||||||
_custom: {
|
_custom: {
|
||||||
...filter._custom,
|
...filter._custom,
|
||||||
navidrome: {
|
navidrome: {
|
||||||
|
...filter._custom?.navidrome,
|
||||||
year: e === '' ? undefined : (e as number),
|
year: e === '' ? undefined : (e as number),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -132,6 +160,25 @@ export const NavidromeSongFilters = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
{tagsQuery.data?.enumTags?.length &&
|
||||||
|
tagsQuery.data.enumTags.map((tag) => (
|
||||||
|
<Group
|
||||||
|
key={tag.name}
|
||||||
|
grow
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
data={tag.options}
|
||||||
|
defaultValue={
|
||||||
|
filter._custom?.navidrome?.[tag.name] as string | undefined
|
||||||
|
}
|
||||||
|
label={tag.name}
|
||||||
|
width={150}
|
||||||
|
onChange={(value) => handleTagFilter(tag.name, value)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
24
src/renderer/features/tag/queries/use-tag-list.ts
Normal file
24
src/renderer/features/tag/queries/use-tag-list.ts
Normal file
|
|
@ -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<TagQuery>) => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue