mirror of
https://github.com/antebudimir/feishin.git
synced 2025-12-31 18:13:31 +00:00
feat: implement folder list view with navigation and playback
- Add folder browsing functionality with breadcrumb navigation - Implement folder playback with recursive song scanning and progress notifications - Add browser history support for back/forward button navigation - Include empty folder detection and loading states
This commit is contained in:
parent
6a236c803a
commit
7a12e4657f
22 changed files with 1237 additions and 2 deletions
|
|
@ -403,6 +403,10 @@
|
|||
"visualizer": "visualizer",
|
||||
"noLyrics": "no lyrics found"
|
||||
},
|
||||
"folderList": {
|
||||
"title": "Folders",
|
||||
"description": "Browse music by folder structure"
|
||||
},
|
||||
"genreList": {
|
||||
"showAlbums": "show $t(entity.genre_one) $t(entity.album_other)",
|
||||
"showTracks": "show $t(entity.genre_one) $t(entity.track_other)",
|
||||
|
|
|
|||
|
|
@ -281,6 +281,20 @@ export const controller: GeneralController = {
|
|||
server.type,
|
||||
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
|
||||
},
|
||||
getFolderList(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(
|
||||
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getFolderList`,
|
||||
);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'getFolderList',
|
||||
server.type,
|
||||
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
|
||||
},
|
||||
getGenreList(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
|
|
|
|||
|
|
@ -386,6 +386,11 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||
|
||||
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
|
||||
},
|
||||
getFolderList: async () => {
|
||||
// Jellyfin doesn't have folder-based navigation like Subsonic
|
||||
// This would need to be implemented using Jellyfin's folder structure
|
||||
throw new Error('Folder browsing not supported for Jellyfin servers');
|
||||
},
|
||||
getGenreList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
|
@ -1085,6 +1090,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||
};
|
||||
},
|
||||
|
||||
updatePlaylist: async (args) => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
|
|
|
|||
|
|
@ -353,6 +353,11 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getDownloadUrl: SubsonicController.getDownloadUrl,
|
||||
getFolderList: async () => {
|
||||
// Navidrome supports Subsonic API, so this should work
|
||||
// But for now, delegate to Subsonic implementation
|
||||
throw new Error('Use Subsonic API endpoint for folder browsing on Navidrome servers');
|
||||
},
|
||||
getGenreList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
|
@ -716,6 +721,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||
id: res.body.data.id,
|
||||
};
|
||||
},
|
||||
|
||||
updatePlaylist: async (args) => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
AlbumDetailQuery,
|
||||
AlbumListQuery,
|
||||
ArtistListQuery,
|
||||
FolderListQuery,
|
||||
GenreListQuery,
|
||||
LyricSearchQuery,
|
||||
LyricsQuery,
|
||||
|
|
@ -158,6 +159,13 @@ export const queryKeys: Record<
|
|||
},
|
||||
root: (serverId: string) => [serverId, 'artists'] as const,
|
||||
},
|
||||
folders: {
|
||||
list: (serverId: string, query?: FolderListQuery) => {
|
||||
if (query) return [serverId, 'folders', 'list', query] as const;
|
||||
return [serverId, 'folders', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'folders'] as const,
|
||||
},
|
||||
genres: {
|
||||
list: (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',
|
||||
|
|
|
|||
|
|
@ -620,6 +620,77 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||
'&c=Feishin'
|
||||
);
|
||||
},
|
||||
getFolderList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
// Check if this is a root music folder ID (single digit like '0', '1', etc.)
|
||||
// These IDs from getMusicFolders are NOT valid directory IDs
|
||||
// We need to use getIndexes instead to get the top-level content
|
||||
const isMusicFolderId = /^\d+$/.test(query.id);
|
||||
|
||||
if (isMusicFolderId) {
|
||||
const res = await ssApiClient(apiClientProps).getIndexes({
|
||||
query: {
|
||||
musicFolderId: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Failed to get folder list: ${JSON.stringify(res.body)}`);
|
||||
}
|
||||
|
||||
// Convert index entries to folder items
|
||||
const items: any[] = [];
|
||||
res.body.indexes?.index?.forEach((idx) => {
|
||||
idx.artist?.forEach((artist) => {
|
||||
items.push({
|
||||
id: artist.id.toString(),
|
||||
imageUrl: null,
|
||||
isDir: true,
|
||||
itemType: 'folder' as const,
|
||||
name: artist.name,
|
||||
serverId: apiClientProps.server?.id || 'unknown',
|
||||
serverType: 'subsonic' as const,
|
||||
title: artist.name,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
items,
|
||||
name: 'Music',
|
||||
parent: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// For actual directory IDs, use getMusicDirectory
|
||||
const requestQuery = {
|
||||
id: query.id,
|
||||
};
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getMusicDirectory({
|
||||
query: requestQuery,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Failed to get folder list: ${JSON.stringify(res.body)}`);
|
||||
}
|
||||
|
||||
const directory = res.body.directory;
|
||||
|
||||
const result = {
|
||||
id: directory.id.toString(),
|
||||
items:
|
||||
directory.child?.map((item) =>
|
||||
ssNormalize.folderItem(item, apiClientProps.server),
|
||||
) || [],
|
||||
name: directory.name,
|
||||
parent: directory.parent,
|
||||
};
|
||||
|
||||
return result;
|
||||
},
|
||||
getGenreList: async ({ apiClientProps, query }) => {
|
||||
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,14 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const FOLDER_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ id: 'play' },
|
||||
{ id: 'playLast' },
|
||||
{ id: 'playNext' },
|
||||
{ divider: true, id: 'playShuffled' },
|
||||
{ divider: true, id: 'addToPlaylist' },
|
||||
];
|
||||
|
||||
export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ id: 'play' },
|
||||
{ id: 'playLast' },
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
Album,
|
||||
AlbumArtist,
|
||||
Artist,
|
||||
FolderItem,
|
||||
LibraryItem,
|
||||
QueueSong,
|
||||
Song,
|
||||
|
|
@ -72,7 +73,7 @@ export const useHandleGeneralContextMenu = (
|
|||
) => {
|
||||
const handleContextMenu = (
|
||||
e: any,
|
||||
data: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[],
|
||||
data: Album[] | AlbumArtist[] | Artist[] | FolderItem[] | QueueSong[] | Song[],
|
||||
) => {
|
||||
if (!e) return;
|
||||
const clickEvent = e as MouseEvent;
|
||||
|
|
|
|||
32
src/renderer/features/folders/api/folder-api.ts
Normal file
32
src/renderer/features/folders/api/folder-api.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { FolderListQuery } from '/@/shared/types/domain-types';
|
||||
|
||||
export const folderQueries = {
|
||||
list: (args: QueryHookArgs<FolderListQuery>) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
return api.controller.getFolderList({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
query: args.query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.folders.list(args.serverId, args.query),
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
musicFolders: (args: { options?: any; serverId: string }) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
return api.controller.getMusicFolderList({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.musicFolders.list(args.serverId),
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
};
|
||||
712
src/renderer/features/folders/components/folder-list-content.tsx
Normal file
712
src/renderer/features/folders/components/folder-list-content.tsx
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { ActionIcon, Box, Breadcrumbs, Group, Stack, Text } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { RiArrowLeftLine, RiFolderLine, RiMusicLine, RiPlayFill } from 'react-icons/ri';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import styles from '../routes/folder-list-route.module.css';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid';
|
||||
import { FOLDER_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { useFolderPath, useFolderStoreActions } from '/@/renderer/store/folder.store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import {
|
||||
FolderItem,
|
||||
FolderListResponse,
|
||||
LibraryItem,
|
||||
MusicFolderListResponse,
|
||||
ServerListItemWithCredential,
|
||||
Song,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface FolderListContentProps {
|
||||
gridRef: MutableRefObject<null | VirtualInfiniteGridRef>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const FolderListContent = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
gridRef: _gridRef,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
itemCount: _itemCount,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
tableRef: _tableRef,
|
||||
}: FolderListContentProps) => {
|
||||
const server = useCurrentServer();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const folderId = searchParams.get('folderId');
|
||||
const folderActions = useFolderStoreActions();
|
||||
const { path } = useFolderPath();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const queryClient = useQueryClient();
|
||||
const [loadingFolderId, setLoadingFolderId] = useState<null | string>(null);
|
||||
const [emptyFolders, setEmptyFolders] = useState<Set<string>>(new Set());
|
||||
const [checkingEmptyFolders, setCheckingEmptyFolders] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Sync breadcrumb path with browser history state
|
||||
useEffect(() => {
|
||||
if (!folderId) {
|
||||
// At root level, reset path
|
||||
folderActions.resetPath();
|
||||
} else {
|
||||
// Try to restore path from history state
|
||||
const historyState = location.state as any;
|
||||
if (historyState?.breadcrumbPath) {
|
||||
folderActions.setPath(historyState.breadcrumbPath);
|
||||
} else {
|
||||
// No history state, reset path to avoid incorrect breadcrumbs
|
||||
folderActions.resetPath();
|
||||
}
|
||||
}
|
||||
}, [folderId, folderActions, location.state]);
|
||||
|
||||
const handleFolderContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.FOLDER,
|
||||
FOLDER_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const folderQuery = useQuery({
|
||||
enabled: !!folderId,
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getFolderList({
|
||||
apiClientProps: {
|
||||
server: server as ServerListItemWithCredential,
|
||||
serverId: server?.id || '',
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: folderId || '',
|
||||
},
|
||||
}),
|
||||
queryKey: ['folder', server?.id, folderId],
|
||||
});
|
||||
|
||||
const musicFoldersQuery = useQuery({
|
||||
enabled: !folderId,
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getMusicFolderList({
|
||||
apiClientProps: {
|
||||
server: server as ServerListItemWithCredential,
|
||||
serverId: server?.id || '',
|
||||
signal,
|
||||
},
|
||||
}),
|
||||
queryKey: ['music-folders', server?.id],
|
||||
});
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (folderId && folderQuery.data) {
|
||||
const data = folderQuery.data as FolderListResponse;
|
||||
return data.items || [];
|
||||
}
|
||||
if (!folderId && musicFoldersQuery.data) {
|
||||
const data = musicFoldersQuery.data as MusicFolderListResponse;
|
||||
return (data.items || []).map((folder) => ({
|
||||
id: folder.id,
|
||||
imageUrl: null,
|
||||
isDir: true,
|
||||
itemType: 'folder' as const,
|
||||
name: folder.name,
|
||||
serverId: server?.id || '',
|
||||
serverType: server?.type || 'subsonic',
|
||||
title: folder.name,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}, [folderId, folderQuery.data, musicFoldersQuery.data, server]);
|
||||
|
||||
// Proactively check which folders are empty
|
||||
useEffect(() => {
|
||||
const checkEmptyFolders = async () => {
|
||||
if (!items || items.length === 0 || !folderId) return;
|
||||
|
||||
setCheckingEmptyFolders(true);
|
||||
const emptyFolderIds: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.isDir && !emptyFolders.has(item.id)) {
|
||||
try {
|
||||
// Fetch folder contents to check if it has any items
|
||||
const folderData = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getFolderList({
|
||||
apiClientProps: {
|
||||
server: server as ServerListItemWithCredential,
|
||||
serverId: server?.id || '',
|
||||
signal,
|
||||
},
|
||||
query: { id: item.id },
|
||||
}),
|
||||
queryKey: ['folder', server?.id, item.id],
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
// If folder has no items, mark it as empty
|
||||
if (!folderData.items || folderData.items.length === 0) {
|
||||
emptyFolderIds.push(item.id);
|
||||
}
|
||||
} catch (error) {
|
||||
// On error, don't mark as empty (assume it might have content)
|
||||
console.warn(`Failed to check folder ${item.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emptyFolderIds.length > 0) {
|
||||
setEmptyFolders((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
emptyFolderIds.forEach((id) => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
setCheckingEmptyFolders(false);
|
||||
};
|
||||
|
||||
checkEmptyFolders();
|
||||
}, [items, folderId, server, queryClient, emptyFolders]);
|
||||
|
||||
const handleFolderClick = useCallback(
|
||||
(item: FolderItem) => {
|
||||
const newFolderId = item.id;
|
||||
const folderName = item.name || item.title || 'Unknown Folder';
|
||||
|
||||
// Update the path in the store
|
||||
folderActions.pushPath({ id: newFolderId, name: folderName });
|
||||
|
||||
// Navigate to the new folder with breadcrumb path in state
|
||||
const currentPath = path || [];
|
||||
navigate(`/library/folders?folderId=${newFolderId}`, {
|
||||
state: { breadcrumbPath: [...currentPath, { id: newFolderId, name: folderName }] },
|
||||
});
|
||||
},
|
||||
[navigate, folderActions, path],
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (path && path.length > 0) {
|
||||
// Navigate to the previous folder in the path
|
||||
const previousFolder = path[path.length - 2]; // Second to last item
|
||||
if (previousFolder) {
|
||||
const newPath = path.slice(0, -1); // Remove last item
|
||||
navigate(`/library/folders?folderId=${previousFolder.id}`, {
|
||||
state: { breadcrumbPath: newPath },
|
||||
});
|
||||
folderActions.setPath(newPath);
|
||||
} else {
|
||||
// Go to root if no previous folder
|
||||
navigate('/library/folders');
|
||||
folderActions.resetPath();
|
||||
}
|
||||
} else {
|
||||
// If no path, go to root
|
||||
navigate('/library/folders');
|
||||
folderActions.resetPath();
|
||||
}
|
||||
}, [navigate, folderActions, path]);
|
||||
|
||||
const handleBreadcrumbClick = useCallback(
|
||||
(index: number) => {
|
||||
const currentPath = path || [];
|
||||
const targetPath = currentPath.slice(0, index + 1);
|
||||
|
||||
// Navigate to the selected folder
|
||||
if (index === -1) {
|
||||
// Root level
|
||||
navigate('/library/folders');
|
||||
folderActions.resetPath();
|
||||
} else {
|
||||
const targetFolder = targetPath[index];
|
||||
navigate(`/library/folders?folderId=${targetFolder.id}`, {
|
||||
state: { breadcrumbPath: targetPath },
|
||||
});
|
||||
folderActions.setPath(targetPath);
|
||||
}
|
||||
},
|
||||
[navigate, folderActions, path],
|
||||
);
|
||||
|
||||
const handlePlaySong = useCallback(
|
||||
(item: FolderItem) => {
|
||||
if (!item.isDir && item.id) {
|
||||
// Play the song
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [item.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
playType: Play.NOW,
|
||||
});
|
||||
}
|
||||
},
|
||||
[handlePlayQueueAdd],
|
||||
);
|
||||
|
||||
// Enhanced recursive function with caching, progress tracking, and cancellation
|
||||
const collectAllSongIds = useCallback(
|
||||
async (
|
||||
folderId: string,
|
||||
signal?: AbortSignal,
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
): Promise<string[]> => {
|
||||
// Check for cancellation
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Operation cancelled');
|
||||
}
|
||||
|
||||
// Try to get from cache first
|
||||
const cacheKey = ['folder-songs', server?.id, folderId];
|
||||
const cached = queryClient.getQueryData<string[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Use React Query's fetchQuery for better caching
|
||||
const folderData = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getFolderList({
|
||||
apiClientProps: {
|
||||
server: server as ServerListItemWithCredential,
|
||||
serverId: server?.id || '',
|
||||
signal,
|
||||
},
|
||||
query: { id: folderId },
|
||||
}),
|
||||
queryKey: ['folder', server?.id, folderId],
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Operation cancelled');
|
||||
}
|
||||
|
||||
const songIds: string[] = [];
|
||||
const subfolders = folderData.items.filter((item) => item.isDir);
|
||||
const songs = folderData.items.filter((item) => !item.isDir);
|
||||
|
||||
// Add immediate song IDs
|
||||
songIds.push(...songs.map((song) => song.id));
|
||||
|
||||
// Process subfolders with controlled concurrency
|
||||
const BATCH_SIZE = 5; // Process 5 folders at a time
|
||||
let processedFolders = 0;
|
||||
|
||||
for (let i = 0; i < subfolders.length; i += BATCH_SIZE) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Operation cancelled');
|
||||
}
|
||||
|
||||
const batch = subfolders.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const batchPromises = batch.map(async (subfolder) => {
|
||||
try {
|
||||
return await collectAllSongIds(subfolder.id, signal, onProgress);
|
||||
} catch (error) {
|
||||
// Log error but continue with other folders
|
||||
console.warn(`Failed to collect songs from folder ${subfolder.id}:`, error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
songIds.push(...batchResults.flat());
|
||||
|
||||
processedFolders += batch.length;
|
||||
onProgress?.(processedFolders, subfolders.length);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
queryClient.setQueryData(cacheKey, songIds, {
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
return songIds;
|
||||
},
|
||||
[server, queryClient],
|
||||
);
|
||||
|
||||
const handlePlayFolder = useCallback(
|
||||
async (e: React.MouseEvent, item: FolderItem | { id: string }) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Cancel any existing operation
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
setLoadingFolderId(item.id);
|
||||
|
||||
const notificationId = notifications.show({
|
||||
autoClose: false,
|
||||
id: 'folder-play-progress',
|
||||
loading: true,
|
||||
message: 'Preparing to play all songs',
|
||||
title: 'Scanning folder...',
|
||||
withCloseButton: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Collect song IDs with progress tracking
|
||||
const songIds = await collectAllSongIds(
|
||||
item.id,
|
||||
abortController.signal,
|
||||
(current, total) => {
|
||||
notifications.update({
|
||||
id: notificationId,
|
||||
loading: true,
|
||||
message: `Processed ${current}/${total} folders`,
|
||||
title: 'Scanning folders...',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (songIds.length > 0) {
|
||||
// Remove from empty folders set if it has songs
|
||||
setEmptyFolders((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(item.id);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Update notification for song fetching
|
||||
notifications.update({
|
||||
id: notificationId,
|
||||
loading: true,
|
||||
message: `Loading ${songIds.length} songs`,
|
||||
title: 'Fetching song details...',
|
||||
});
|
||||
|
||||
// Fetch song details in batches to avoid overwhelming the server
|
||||
const BATCH_SIZE = 20;
|
||||
const allSongs: Song[] = [];
|
||||
|
||||
for (let i = 0; i < songIds.length; i += BATCH_SIZE) {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = songIds.slice(i, i + BATCH_SIZE);
|
||||
const songPromises = batch.map((songId) =>
|
||||
api.controller.getSongDetail({
|
||||
apiClientProps: {
|
||||
server: server as ServerListItemWithCredential,
|
||||
serverId: server?.id || '',
|
||||
signal: abortController.signal,
|
||||
},
|
||||
query: { id: songId },
|
||||
}),
|
||||
);
|
||||
|
||||
const batchSongs = await Promise.all(songPromises);
|
||||
allSongs.push(...batchSongs);
|
||||
|
||||
// Update progress
|
||||
const loadedSongs = Math.min(i + BATCH_SIZE, songIds.length);
|
||||
notifications.update({
|
||||
id: notificationId,
|
||||
loading: true,
|
||||
message: `Loaded ${loadedSongs}/${songIds.length} songs`,
|
||||
title: 'Fetching song details...',
|
||||
});
|
||||
}
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Play the songs
|
||||
handlePlayQueueAdd?.({
|
||||
byData: allSongs,
|
||||
playType: Play.NOW,
|
||||
});
|
||||
|
||||
// Show success notification
|
||||
notifications.update({
|
||||
autoClose: 3000,
|
||||
color: 'green',
|
||||
id: notificationId,
|
||||
loading: false,
|
||||
message: `Started playing ${allSongs.length} songs`,
|
||||
title: 'Playing folder',
|
||||
});
|
||||
} else {
|
||||
// Mark folder as empty
|
||||
setEmptyFolders((prev) => new Set(prev).add(item.id));
|
||||
|
||||
notifications.update({
|
||||
autoClose: 3000,
|
||||
color: 'yellow',
|
||||
id: notificationId,
|
||||
loading: false,
|
||||
message: 'This folder contains no playable songs',
|
||||
title: 'No songs found',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
notifications.update({
|
||||
autoClose: 2000,
|
||||
color: 'orange',
|
||||
id: notificationId,
|
||||
loading: false,
|
||||
message: 'Folder playback was cancelled',
|
||||
title: 'Operation cancelled',
|
||||
});
|
||||
} else {
|
||||
notifications.update({
|
||||
autoClose: 5000,
|
||||
color: 'red',
|
||||
id: notificationId,
|
||||
loading: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
title: 'Failed to play folder',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoadingFolderId(null);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[collectAllSongIds, handlePlayQueueAdd, server],
|
||||
);
|
||||
|
||||
const handleCancelPlayFolder = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
setLoadingFolderId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack className={styles.container} gap="md">
|
||||
{folderId && (
|
||||
<Group p="md">
|
||||
<Box
|
||||
aria-label="Go back to previous folder"
|
||||
className={styles['back-button']}
|
||||
component="button"
|
||||
onClick={handleBack}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleBack();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<RiArrowLeftLine size={24} />
|
||||
</Box>
|
||||
<Breadcrumbs
|
||||
className={styles.breadcrumbs}
|
||||
separator={
|
||||
<Text c="dimmed" size="sm">
|
||||
/
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
className={styles['breadcrumb-item']}
|
||||
onClick={() => handleBreadcrumbClick(-1)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleBreadcrumbClick(-1);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Text
|
||||
aria-label="Go to folders list"
|
||||
c="blue"
|
||||
className={styles['breadcrumb-text']}
|
||||
size="sm"
|
||||
>
|
||||
Folders
|
||||
</Text>
|
||||
</Box>
|
||||
{path?.map((pathItem, index) =>
|
||||
index < path.length - 1 ? (
|
||||
<Box
|
||||
className={styles['breadcrumb-item']}
|
||||
key={pathItem.id}
|
||||
onClick={() => handleBreadcrumbClick(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleBreadcrumbClick(index);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Text
|
||||
aria-label={`Navigate to: ${pathItem.name}`}
|
||||
c="blue"
|
||||
className={styles['breadcrumb-text']}
|
||||
size="sm"
|
||||
>
|
||||
{pathItem.name}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text
|
||||
aria-label={`Current folder: ${pathItem.name}`}
|
||||
c="white"
|
||||
className={styles['current-breadcrumb-text']}
|
||||
key={pathItem.id}
|
||||
role="text"
|
||||
size="sm"
|
||||
>
|
||||
{pathItem.name}
|
||||
</Text>
|
||||
),
|
||||
)}
|
||||
</Breadcrumbs>
|
||||
</Group>
|
||||
)}
|
||||
{!folderId && (
|
||||
<Text fw={700} p="md" size="xl">
|
||||
Folders
|
||||
</Text>
|
||||
)}
|
||||
{folderQuery.isLoading && folderId && <Text p="md">Loading...</Text>}
|
||||
{musicFoldersQuery.isLoading && !folderId && <Text p="md">Loading...</Text>}
|
||||
{folderQuery.error && folderId && (
|
||||
<Stack gap="xs" p="md">
|
||||
<Text c="red">Error: {(folderQuery.error as Error)?.message}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
Note: Browsing root folders requires using artist indexes. Try browsing from
|
||||
Albums or Artists views instead, or navigate directly to a subfolder if you
|
||||
know its ID.
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{musicFoldersQuery.error && !folderId && (
|
||||
<Text c="red" p="md">
|
||||
Error: {(musicFoldersQuery.error as Error)?.message}
|
||||
</Text>
|
||||
)}
|
||||
<Box aria-label="Folder contents" className={styles['folder-content']} role="main">
|
||||
<Stack
|
||||
aria-label={`${folderId ? 'Folder contents' : 'Folders'} list`}
|
||||
gap="xs"
|
||||
role="list"
|
||||
>
|
||||
{checkingEmptyFolders ? (
|
||||
<Group justify="center" p="md">
|
||||
<Spinner container />
|
||||
<Text c="dimmed" size="sm">
|
||||
Checking folder contents...
|
||||
</Text>
|
||||
</Group>
|
||||
) : items.length === 0 &&
|
||||
!folderQuery.isLoading &&
|
||||
!musicFoldersQuery.isLoading ? (
|
||||
<Text c="dimmed" p="md">
|
||||
No items found
|
||||
</Text>
|
||||
) : null}
|
||||
{!checkingEmptyFolders &&
|
||||
items.map((item) => (
|
||||
<Group
|
||||
aria-label={`${item.isDir ? 'Folder' : 'Song'}: ${item.title || item.name}`}
|
||||
className={styles['folder-item']}
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
item.isDir ? handleFolderClick(item) : handlePlaySong(item)
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
if (item.isDir) {
|
||||
handleFolderContextMenu(e, [item]);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (item.isDir) {
|
||||
handleFolderClick(item);
|
||||
} else {
|
||||
handlePlaySong(item);
|
||||
}
|
||||
}
|
||||
}}
|
||||
p="sm"
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
>
|
||||
{item.isDir ? (
|
||||
<RiFolderLine size={24} />
|
||||
) : (
|
||||
<RiMusicLine size={24} />
|
||||
)}
|
||||
{item.isDir && !emptyFolders.has(item.id) && folderId && (
|
||||
<Tooltip
|
||||
label={loadingFolderId === item.id ? 'Stop' : 'Play all'}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={
|
||||
loadingFolderId === item.id
|
||||
? 'Stop playing folder'
|
||||
: `Play folder: ${item.title || item.name}`
|
||||
}
|
||||
color={loadingFolderId === item.id ? 'red' : 'blue'}
|
||||
onClick={(e) => {
|
||||
if (loadingFolderId === item.id) {
|
||||
handleCancelPlayFolder();
|
||||
} else {
|
||||
handlePlayFolder(e, item);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (loadingFolderId === item.id) {
|
||||
handleCancelPlayFolder();
|
||||
} else {
|
||||
handlePlayFolder(e as any, item);
|
||||
}
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
size="lg"
|
||||
tabIndex={0}
|
||||
variant="subtle"
|
||||
>
|
||||
{loadingFolderId === item.id ? (
|
||||
<Spinner size={20} />
|
||||
) : (
|
||||
<RiPlayFill size={20} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text className={styles['song-title']}>
|
||||
{item.title || item.name}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
|
||||
export const FolderListHeader = (): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
return (
|
||||
<Stack gap={0} ref={cq.ref}>
|
||||
<PageHeader>
|
||||
<Flex justify="space-between" w="100%">
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>
|
||||
{t('page.folderList.title', { postProcess: 'titleCase' })}
|
||||
</LibraryHeaderBar.Title>
|
||||
</LibraryHeaderBar>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
.container {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
background-color: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.breadcrumb-text {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.current-breadcrumb-text {
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.folder-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background-color: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.song-title {
|
||||
flex: 1;
|
||||
}
|
||||
32
src/renderer/features/folders/routes/folder-list-route.tsx
Normal file
32
src/renderer/features/folders/routes/folder-list-route.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { FolderListContent } from '/@/renderer/features/folders/components/folder-list-content';
|
||||
import { FolderListHeader } from '/@/renderer/features/folders/components/folder-list-header';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||
|
||||
const FolderListRoute = () => {
|
||||
const gridRef = useRef<null | VirtualInfiniteGridRef>(null);
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const pageKey = 'folder';
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
pageKey,
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<ListContext.Provider value={providerValue}>
|
||||
<FolderListHeader />
|
||||
<FolderListContent gridRef={gridRef} itemCount={undefined} tableRef={tableRef} />
|
||||
</ListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderListRoute;
|
||||
|
|
@ -49,6 +49,7 @@ export const Sidebar = () => {
|
|||
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
|
||||
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
|
||||
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ const DummyAlbumDetailRoute = lazy(
|
|||
|
||||
const GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route'));
|
||||
|
||||
const FolderListRoute = lazy(() => import('/@/renderer/features/folders/routes/folder-list-route'));
|
||||
|
||||
const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route'));
|
||||
|
||||
const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
|
||||
|
|
@ -161,6 +163,11 @@ export const AppRouter = () => {
|
|||
errorElement={<RouteErrorBoundary />}
|
||||
path={AppRoute.LIBRARY_SONGS}
|
||||
/>
|
||||
<Route
|
||||
element={<FolderListRoute />}
|
||||
errorElement={<RouteErrorBoundary />}
|
||||
path={AppRoute.LIBRARY_FOLDERS}
|
||||
/>
|
||||
<Route
|
||||
element={<PlaylistListRoute />}
|
||||
errorElement={<RouteErrorBoundary />}
|
||||
|
|
|
|||
70
src/renderer/store/folder.store.ts
Normal file
70
src/renderer/store/folder.store.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { devtools } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
export interface FolderStoreSlice extends FolderStoreState {
|
||||
actions: {
|
||||
popPath: () => void;
|
||||
pushPath: (path: { id: string; name: string }) => void;
|
||||
resetPath: () => void;
|
||||
setCurrentFolderId: (id: null | string) => void;
|
||||
setPath: (path: Array<{ id: string; name: string }>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FolderStoreState {
|
||||
currentFolderId: null | string;
|
||||
path: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export const useFolderStore = createWithEqualityFn<FolderStoreSlice>()(
|
||||
devtools(
|
||||
immer((set) => ({
|
||||
actions: {
|
||||
popPath: () => {
|
||||
set((state) => {
|
||||
if (state.path.length > 0) {
|
||||
state.path.pop();
|
||||
state.currentFolderId =
|
||||
state.path.length > 0 ? state.path[state.path.length - 1].id : null;
|
||||
}
|
||||
});
|
||||
},
|
||||
pushPath: (pathItem) => {
|
||||
set((state) => {
|
||||
state.path.push(pathItem);
|
||||
state.currentFolderId = pathItem.id;
|
||||
});
|
||||
},
|
||||
resetPath: () => {
|
||||
set((state) => {
|
||||
state.path = [];
|
||||
state.currentFolderId = null;
|
||||
});
|
||||
},
|
||||
setCurrentFolderId: (id) => {
|
||||
set((state) => {
|
||||
state.currentFolderId = id;
|
||||
});
|
||||
},
|
||||
setPath: (path) => {
|
||||
set((state) => {
|
||||
state.path = path;
|
||||
state.currentFolderId = path.length > 0 ? path[path.length - 1].id : null;
|
||||
});
|
||||
},
|
||||
},
|
||||
currentFolderId: null,
|
||||
path: [],
|
||||
})),
|
||||
{ name: 'store_folder' },
|
||||
),
|
||||
);
|
||||
|
||||
export const useFolderStoreActions = () => useFolderStore((state) => state.actions);
|
||||
|
||||
export const useFolderPath = () =>
|
||||
useFolderStore((state) => ({
|
||||
currentFolderId: state.currentFolderId,
|
||||
path: state.path,
|
||||
}));
|
||||
|
|
@ -475,6 +475,12 @@ export const sidebarItems: SidebarItemType[] = [
|
|||
label: i18n.t('page.sidebar.genres'),
|
||||
route: AppRoute.LIBRARY_GENRES,
|
||||
},
|
||||
{
|
||||
disabled: false,
|
||||
id: 'Folders',
|
||||
label: i18n.t('page.sidebar.folders'),
|
||||
route: AppRoute.LIBRARY_FOLDERS,
|
||||
},
|
||||
{
|
||||
disabled: true,
|
||||
id: 'Playlists',
|
||||
|
|
|
|||
|
|
@ -334,9 +334,51 @@ const normalizeGenre = (item: z.infer<typeof ssType._response.genre>): Genre =>
|
|||
};
|
||||
};
|
||||
|
||||
const normalizeFolderItem = (
|
||||
item: z.infer<typeof ssType._response.directoryChild>,
|
||||
server?: null | ServerListItemWithCredential,
|
||||
): import('/@/shared/types/domain-types').FolderItem => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt?.toString(),
|
||||
credential: server?.credential,
|
||||
size: 300,
|
||||
}) || null;
|
||||
|
||||
return {
|
||||
album: item.album,
|
||||
albumId: item.albumId?.toString(),
|
||||
artist: item.artist,
|
||||
artistId: item.artistId?.toString(),
|
||||
coverArt: item.coverArt?.toString(),
|
||||
created: item.created,
|
||||
duration: item.duration,
|
||||
genre: item.genre,
|
||||
id: item.id.toString(),
|
||||
imageUrl,
|
||||
isDir: item.isDir,
|
||||
itemType: LibraryItem.FOLDER,
|
||||
name: item.title,
|
||||
parent: item.parent,
|
||||
path: item.path,
|
||||
playCount: item.playCount,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: item.size,
|
||||
starred: !!item.starred,
|
||||
suffix: item.suffix,
|
||||
title: item.title,
|
||||
track: item.track,
|
||||
userRating: item.userRating,
|
||||
year: item.year,
|
||||
};
|
||||
};
|
||||
|
||||
export const ssNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
folderItem: normalizeFolderItem,
|
||||
genre: normalizeGenre,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
|
|
|
|||
|
|
@ -548,6 +548,73 @@ const albumInfo = z.object({
|
|||
}),
|
||||
});
|
||||
|
||||
const directoryChild = z.object({
|
||||
album: z.string().optional(),
|
||||
albumId: id.optional(),
|
||||
artist: z.string().optional(),
|
||||
artistId: id.optional(),
|
||||
averageRating: z.number().optional(),
|
||||
contentType: z.string().optional(),
|
||||
coverArt: z.string().optional(),
|
||||
created: z.string().optional(),
|
||||
duration: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
id,
|
||||
isDir: z.boolean(),
|
||||
isVideo: z.boolean().optional(),
|
||||
parent: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
playCount: z.number().optional(),
|
||||
size: z.number().optional(),
|
||||
starred: z.string().optional(),
|
||||
suffix: z.string().optional(),
|
||||
title: z.string(),
|
||||
track: z.number().optional(),
|
||||
type: z.string().optional(),
|
||||
userRating: z.number().optional(),
|
||||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
const directory = z.object({
|
||||
averageRating: z.number().optional(),
|
||||
child: z.array(directoryChild).optional(),
|
||||
id,
|
||||
name: z.string(),
|
||||
parent: z.string().optional(),
|
||||
playCount: z.number().optional(),
|
||||
starred: z.string().optional(),
|
||||
userRating: z.number().optional(),
|
||||
});
|
||||
|
||||
const getMusicDirectoryParameters = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const getMusicDirectory = z.object({
|
||||
directory,
|
||||
});
|
||||
|
||||
const getIndexesParameters = z.object({
|
||||
ifModifiedSince: z.number().optional(),
|
||||
musicFolderId: z.string().optional(),
|
||||
});
|
||||
|
||||
const getIndexes = z.object({
|
||||
indexes: z.object({
|
||||
ignoredArticles: z.string().optional(),
|
||||
index: z
|
||||
.array(
|
||||
z.object({
|
||||
artist: z.array(artistListEntry).optional(),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
lastModified: z.number(),
|
||||
shortcut: z.array(artistListEntry).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ssType = {
|
||||
_parameters: {
|
||||
albumInfo: albumInfoParameters,
|
||||
|
|
@ -563,6 +630,8 @@ export const ssType = {
|
|||
getArtists: getArtistsParameters,
|
||||
getGenre: getGenresParameters,
|
||||
getGenres: getGenresParameters,
|
||||
getIndexes: getIndexesParameters,
|
||||
getMusicDirectory: getMusicDirectoryParameters,
|
||||
getPlaylist: getPlaylistParameters,
|
||||
getPlaylists: getPlaylistsParameters,
|
||||
getSong: getSongParameters,
|
||||
|
|
@ -591,12 +660,16 @@ export const ssType = {
|
|||
baseResponse,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
directory,
|
||||
directoryChild,
|
||||
genre,
|
||||
getAlbum,
|
||||
getAlbumList2,
|
||||
getArtist,
|
||||
getArtists,
|
||||
getGenres,
|
||||
getIndexes,
|
||||
getMusicDirectory,
|
||||
getPlaylist,
|
||||
getPlaylists,
|
||||
getSong,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export enum LibraryItem {
|
|||
ALBUM = 'album',
|
||||
ALBUM_ARTIST = 'albumArtist',
|
||||
ARTIST = 'artist',
|
||||
FOLDER = 'folder',
|
||||
GENRE = 'genre',
|
||||
PLAYLIST = 'playlist',
|
||||
SONG = 'song',
|
||||
|
|
@ -255,6 +256,47 @@ export type EndpointDetails = {
|
|||
server: ServerListItem;
|
||||
};
|
||||
|
||||
export type FolderItem = {
|
||||
album?: string;
|
||||
albumId?: string;
|
||||
artist?: string;
|
||||
artistId?: string;
|
||||
coverArt?: string;
|
||||
created?: string;
|
||||
duration?: number;
|
||||
genre?: string;
|
||||
id: string;
|
||||
imageUrl: null | string;
|
||||
isDir: boolean;
|
||||
itemType: LibraryItem.FOLDER;
|
||||
name: string;
|
||||
parent?: string;
|
||||
path?: string;
|
||||
playCount?: number;
|
||||
serverId: string;
|
||||
serverType: ServerType;
|
||||
size?: number;
|
||||
starred?: boolean;
|
||||
suffix?: string;
|
||||
title: string;
|
||||
track?: number;
|
||||
userRating?: number;
|
||||
year?: number;
|
||||
};
|
||||
|
||||
export type FolderListArgs = BaseEndpointArgs & { query: FolderListQuery };
|
||||
|
||||
export interface FolderListQuery {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type FolderListResponse = {
|
||||
id: string;
|
||||
items: FolderItem[];
|
||||
name: string;
|
||||
parent?: string;
|
||||
};
|
||||
|
||||
export type GainInfo = {
|
||||
album?: number;
|
||||
track?: number;
|
||||
|
|
@ -1236,6 +1278,7 @@ export type ControllerEndpoint = {
|
|||
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
|
||||
getDownloadUrl: (args: DownloadArgs) => string;
|
||||
getFolderList: (args: FolderListArgs) => Promise<FolderListResponse>;
|
||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
|
||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||
|
|
@ -1314,6 +1357,7 @@ export type InternalControllerEndpoint = {
|
|||
getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>;
|
||||
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
|
||||
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
||||
getFolderList: (args: ReplaceApiClientProps<FolderListArgs>) => Promise<FolderListResponse>;
|
||||
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
||||
getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>;
|
||||
getMusicFolderList: (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue