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:
Kendall Garner 2025-05-18 09:23:52 -07:00
parent b0d86ee5c9
commit e1aa8d74f3
No known key found for this signature in database
GPG key ID: 9355F387FE765C94
17 changed files with 360 additions and 16 deletions

View file

@ -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',

View file

@ -46,6 +46,8 @@ const NAVIDROME_ROLES: Array<string | { label: string; value: string }> = [
'remixer',
];
const EXCLUDED_TAGS = new Set<string>(['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<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,
getTranscodingUrl: SubsonicController.getTranscodingUrl,
getUserList: async (args) => {

View file

@ -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,