add folder browsing support (#315)

This commit is contained in:
jeffvli
2025-12-02 21:30:44 -08:00
parent 355257104d
commit 917bf91583
53 changed files with 2382 additions and 299 deletions
+18
View File
@@ -306,6 +306,24 @@ export const controller: GeneralController = {
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
getFolder(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getFolder`,
);
}
return apiController(
'getFolder',
server.type,
)?.({
...args,
apiClientProps: { ...args.apiClientProps, server },
query: mergeMusicFolderId(args.query, server),
});
},
getGenreList(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -116,6 +116,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
getFolder: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.folder,
responses: {
200: jfType._response.folderList,
400: jfType._response.error,
},
},
getGenreList: {
method: 'GET',
path: 'musicgenres',
@@ -1,20 +1,26 @@
import chunk from 'lodash/chunk';
import filter from 'lodash/filter';
import orderBy from 'lodash/orderBy';
import { z } from 'zod';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';
import { JFSongListSort, JFSortOrder, jfType } from '/@/shared/api/jellyfin/jellyfin-types';
import { getFeatures, hasFeature, VersionInfo } from '/@/shared/api/utils';
import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/api/utils';
import {
albumArtistListSortMap,
albumListSortMap,
Folder,
genreListSortMap,
InternalControllerEndpoint,
LibraryItem,
Played,
playlistListSortMap,
ServerType,
Song,
SongListSort,
songListSortMap,
SortOrder,
sortOrderMap,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
@@ -386,6 +392,213 @@ export const JellyfinController: InternalControllerEndpoint = {
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
},
getFolder: async ({ apiClientProps, query }) => {
const userId = apiClientProps.server?.userId;
if (!userId) throw new Error('No userId found');
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
const isRootFolderId = query.id === '0';
if (isRootFolderId) {
if (query.musicFolderId) {
// If music folder is provided, directly get the folder
const musicFolderRes = await jfApiClient(apiClientProps).getFolder({
params: {
userId,
},
query: {
ParentId: getLibraryId(query.musicFolderId)!,
},
});
if (musicFolderRes.status !== 200) {
throw new Error('Failed to get music folder list');
}
let items = musicFolderRes.body.Items.filter((item) => item.Type !== 'Audio');
if (query.searchTerm) {
items = filter(items, (item) => {
return item.Name.toLowerCase().includes(query.searchTerm!.toLowerCase());
});
}
const folders = items
.filter((item) => item.Type !== 'Audio')
.map((item) => jfNormalize.folder(item, apiClientProps.server));
const sortedFolders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);
return {
_itemType: LibraryItem.FOLDER,
_serverId: apiClientProps.server?.id || 'unknown',
_serverType: ServerType.JELLYFIN,
children: {
folders: sortedFolders,
songs: [],
},
id: query.id,
name: '~',
parentId: undefined,
};
} else {
// Use the root music folder list if no music folder id is provided
const musicFolderRes = await jfApiClient(apiClientProps).getMusicFolderList({
params: {
userId,
},
});
if (musicFolderRes.status !== 200) {
throw new Error('Failed to get music folder list');
}
let items = musicFolderRes.body.Items.filter((item) => item.Type !== 'Audio');
if (query.searchTerm) {
items = filter(items, (item) => {
return item.Name.toLowerCase().includes(query.searchTerm!.toLowerCase());
});
}
const folders = items
.filter((item) => item.Type !== 'Audio')
.map((item) =>
jfNormalize.folder(
item as unknown as z.infer<typeof jfType._response.folder>,
apiClientProps.server,
),
);
const sortedFolders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);
return {
_itemType: LibraryItem.FOLDER,
_serverId: apiClientProps.server?.id || 'unknown',
_serverType: ServerType.JELLYFIN,
children: {
folders: sortedFolders,
songs: [],
},
id: query.id,
name: '~',
parentId: undefined,
};
}
}
const folderDetailRes = await jfApiClient(apiClientProps).getFolder({
params: {
userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
ParentId: query.id,
SortBy: query.sortBy
? (songListSortMap.jellyfin[query.sortBy] as string) || 'SortName'
: 'SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder || SortOrder.ASC],
},
});
if (folderDetailRes.status !== 200) {
throw new Error('Failed to get folder');
}
// Get parent folder info - we'll use the first child's ParentId to infer the folder's parentId
// The folder name will be inferred from the query.id or we can try to get it from a parent query
let parentId: string | undefined;
let folderName = 'Unknown folder';
if (folderDetailRes.body.Items?.length > 0) {
const firstItem = folderDetailRes.body.Items[0];
parentId = firstItem.ParentId;
// Try to get the folder name by querying its parent's children
if (parentId) {
const parentFolderRes = await jfApiClient(apiClientProps).getFolder({
params: {
userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
ParentId: parentId,
},
});
if (parentFolderRes.status === 200) {
const parentFolderItem = parentFolderRes.body.Items?.find(
(item) => item.Id === query.id,
);
if (parentFolderItem) {
folderName = parentFolderItem.Name || 'Unknown folder';
parentId = parentFolderItem.ParentId;
}
}
}
}
const items = folderDetailRes.body.Items || [];
let filteredFolders = items
.filter((item) => item.Type !== 'Audio')
.map((item) => jfNormalize.folder(item, apiClientProps.server));
let filteredSongs = items
.filter(
(item) =>
item.Type === 'Audio' &&
(item as unknown as z.infer<typeof jfType._response.song>).MediaSources,
)
.map((item) =>
jfNormalize.song(
item as unknown as z.infer<typeof jfType._response.song>,
apiClientProps.server,
),
);
if (query.searchTerm) {
const searchTermLower = query.searchTerm.toLowerCase();
filteredFolders = filter(filteredFolders, (f) =>
f.name.toLowerCase().includes(searchTermLower),
);
filteredSongs = filter(filteredSongs, (s) => {
const name = s.name?.toLowerCase() || '';
const album = s.album?.toLowerCase() || '';
const artist = s.artistName?.toLowerCase() || '';
return (
name.includes(searchTermLower) ||
album.includes(searchTermLower) ||
artist.includes(searchTermLower)
);
});
}
filteredFolders = orderBy(filteredFolders, [(v) => v.name.toLowerCase()], [sortOrder]);
if (filteredSongs.length > 0) {
filteredSongs = sortSongList(
filteredSongs,
query.sortBy || SongListSort.NAME,
query.sortOrder || SortOrder.ASC,
);
}
const folder: Folder = {
_itemType: LibraryItem.FOLDER,
_serverId: apiClientProps.server?.id || 'unknown',
_serverType: ServerType.JELLYFIN,
children: {
folders: filteredFolders,
songs: filteredSongs,
},
id: query.id,
name: folderName,
parentId,
};
return folder;
},
getGenreList: async (args) => {
const { apiClientProps, query } = args;
@@ -393,6 +393,7 @@ export const NavidromeController: InternalControllerEndpoint = {
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getDownloadUrl: SubsonicController.getDownloadUrl,
getFolder: SubsonicController.getFolder,
getGenreList: async (args) => {
const { apiClientProps, query } = args;
+10
View File
@@ -4,6 +4,7 @@ import type {
AlbumDetailQuery,
AlbumListQuery,
ArtistListQuery,
FolderQuery,
GenreListQuery,
LyricSearchQuery,
LyricsQuery,
@@ -224,6 +225,15 @@ export const queryKeys: Record<
},
root: (serverId: string) => [serverId, 'artists'] as const,
},
folders: {
folder: (serverId: string, query?: FolderQuery) => {
if (query) {
return [serverId, 'folders', 'folder', query] as const;
}
return [serverId, 'folders', 'folder'] as const;
},
},
genres: {
count: (serverId: string, query?: GenreListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);
+16
View File
@@ -100,6 +100,22 @@ export const contract = c.router({
200: ssType._response.getGenres,
},
},
getIndexes: {
method: 'GET',
path: 'getIndexes.view',
query: ssType._parameters.getIndexes,
responses: {
200: ssType._response.getIndexes,
},
},
getMusicDirectory: {
method: 'GET',
path: 'getMusicDirectory.view',
query: ssType._parameters.getMusicDirectory,
responses: {
200: ssType._response.getMusicDirectory,
},
},
getMusicFolderList: {
method: 'GET',
path: 'getMusicFolders.view',
@@ -21,7 +21,9 @@ import {
InternalControllerEndpoint,
LibraryItem,
PlaylistListSort,
ServerType,
Song,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { ServerFeatures } from '/@/shared/types/features-types';
@@ -650,6 +652,106 @@ export const SubsonicController: InternalControllerEndpoint = {
'&c=Feishin'
);
},
getFolder: async ({ apiClientProps, query }) => {
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
const isRootFolderId = /^\d+$/.test(query.id);
if (isRootFolderId) {
const res = await ssApiClient(apiClientProps).getIndexes({
query: {
musicFolderId: getLibraryId(query.musicFolderId),
},
});
if (res.status !== 200) {
throw new Error(`Failed to get folder list: ${JSON.stringify(res.body)}`);
}
let items =
res.body.indexes?.index?.flatMap((idx) =>
idx.artist.map((artist) => ({
artist: artist.name,
id: artist.id.toString(),
isDir: true,
title: artist.name,
})),
) || [];
if (query.searchTerm) {
items = filter(items, (item) => {
return item.title.toLowerCase().includes(query.searchTerm!.toLowerCase());
});
}
let folders = items.map((item) => ssNormalize.folder(item, apiClientProps.server));
folders = orderBy(folders, [(v) => v.name.toLowerCase()], [sortOrder]);
return {
_itemType: LibraryItem.FOLDER,
_serverId: apiClientProps.server?.id || 'unknown',
_serverType: ServerType.SUBSONIC,
children: {
folders,
songs: [],
},
id: query.id,
name: '~',
parentId: undefined,
};
}
const directoryRes = await ssApiClient(apiClientProps).getMusicDirectory({
query: {
id: query.id,
},
});
if (directoryRes.status !== 200) {
throw new Error('Failed to get folder');
}
const folder = ssNormalize.folder(directoryRes.body.directory, apiClientProps.server);
let filteredFolders = folder.children?.folders || [];
let filteredSongs = folder.children?.songs || [];
if (query.searchTerm) {
const searchTermLower = query.searchTerm.toLowerCase();
filteredFolders = filter(filteredFolders, (f) =>
f.name.toLowerCase().includes(searchTermLower),
);
filteredSongs = filter(filteredSongs, (s) => {
const name = s.name?.toLowerCase() || '';
const album = s.album?.toLowerCase() || '';
const artist = s.artistName?.toLowerCase() || '';
return (
name.includes(searchTermLower) ||
album.includes(searchTermLower) ||
artist.includes(searchTermLower)
);
});
}
filteredFolders = orderBy(filteredFolders, [(v) => v.name.toLowerCase()], [sortOrder]);
if (filteredSongs.length > 0) {
filteredSongs = sortSongList(
filteredSongs,
query.sortBy || SongListSort.NAME,
query.sortOrder || SortOrder.ASC,
);
}
return {
...folder,
children: {
folders: filteredFolders,
songs: filteredSongs,
},
};
},
getGenreList: async ({ apiClientProps, query }) => {
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';