mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 13:00:13 +02:00
add folder browsing support (#315)
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user