mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-22 18:36:34 +02:00
add scan listener and query invalidation on tag editor save
This commit is contained in:
@@ -173,7 +173,11 @@
|
|||||||
"gridRows": "Grid rows",
|
"gridRows": "Grid rows",
|
||||||
"tableColumns": "Table columns",
|
"tableColumns": "Table columns",
|
||||||
"itemsMore": "{{count}} more",
|
"itemsMore": "{{count}} more",
|
||||||
"newVersionAvailable": "A new version is available"
|
"lastScan": "Last scan {{date}}",
|
||||||
|
"newVersionAvailable": "A new version is available",
|
||||||
|
"scanFolderCount": "{{count}} folders",
|
||||||
|
"scanItemCount": "{{count}} items",
|
||||||
|
"scanningLibrary": "Scanning library…"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"album_one": "Album",
|
"album_one": "Album",
|
||||||
|
|||||||
@@ -612,6 +612,18 @@ export const controller: GeneralController = {
|
|||||||
server.type,
|
server.type,
|
||||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||||
},
|
},
|
||||||
|
getScanStatus(args) {
|
||||||
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(`${i18n.t('error.apiRouteError')}: getScanStatus`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiController(
|
||||||
|
'getScanStatus',
|
||||||
|
server.type,
|
||||||
|
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||||
|
},
|
||||||
getServerInfo(args) {
|
getServerInfo(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,14 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getScheduledTasks: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'ScheduledTasks',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.scheduledTasks,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
getServerInfo: {
|
getServerInfo: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'system/info',
|
path: 'system/info',
|
||||||
|
|||||||
@@ -1161,6 +1161,34 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getRoles: async () => [],
|
getRoles: async () => [],
|
||||||
|
getScanStatus: async (args) => {
|
||||||
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getScheduledTasks();
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get scan status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const task =
|
||||||
|
res.body.find((t) => t.Key === 'RefreshLibrary') ||
|
||||||
|
res.body.find((t) => t.Name === 'Scan Media Library');
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return {
|
||||||
|
count: 0,
|
||||||
|
folderCount: 0,
|
||||||
|
scanning: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: 0,
|
||||||
|
folderCount: 0,
|
||||||
|
lastScan: task.LastExecutionResult?.EndTimeUtc ?? undefined,
|
||||||
|
scanning: task.State === 'Running' || task.State === 'Cancelling',
|
||||||
|
};
|
||||||
|
},
|
||||||
getServerInfo: async (args) => {
|
getServerInfo: async (args) => {
|
||||||
const { apiClientProps } = args;
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -723,6 +723,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
getRandomSongList: SubsonicController.getRandomSongList,
|
getRandomSongList: SubsonicController.getRandomSongList,
|
||||||
getRoles: async ({ apiClientProps }) =>
|
getRoles: async ({ apiClientProps }) =>
|
||||||
hasFeature(apiClientProps.server, ServerFeature.BFR) ? NAVIDROME_ROLES : [],
|
hasFeature(apiClientProps.server, ServerFeature.BFR) ? NAVIDROME_ROLES : [],
|
||||||
|
getScanStatus: SubsonicController.getScanStatus,
|
||||||
getServerInfo: async (args) => {
|
getServerInfo: async (args) => {
|
||||||
const { apiClientProps } = args;
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ export const queryKeys: Record<
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
root: (serverId: string) => [serverId] as const,
|
root: (serverId: string) => [serverId] as const,
|
||||||
|
scanStatus: (serverId: string) => [serverId, 'server', 'scanStatus'] as const,
|
||||||
},
|
},
|
||||||
songs: {
|
songs: {
|
||||||
albumRadio: (serverId: string, query?: AlbumRadioQuery) => {
|
albumRadio: (serverId: string, query?: AlbumRadioQuery) => {
|
||||||
|
|||||||
@@ -1355,6 +1355,22 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
final.splice(0, 0, { label: 'all artists', value: '' });
|
final.splice(0, 0, { label: 'all artists', value: '' });
|
||||||
return final;
|
return final;
|
||||||
},
|
},
|
||||||
|
getScanStatus: async (args) => {
|
||||||
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).getScanStatus({ query: {} });
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get scan status');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: res.body.scanStatus.count,
|
||||||
|
folderCount: res.body.scanStatus.folderCount,
|
||||||
|
lastScan: res.body.scanStatus.lastScan,
|
||||||
|
scanning: res.body.scanStatus.scanning,
|
||||||
|
};
|
||||||
|
},
|
||||||
getServerInfo: async (args) => {
|
getServerInfo: async (args) => {
|
||||||
const { apiClientProps } = args;
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type EventMap = {
|
|||||||
PLAYLIST_MOVE_UP: PlaylistMoveEventPayload;
|
PLAYLIST_MOVE_UP: PlaylistMoveEventPayload;
|
||||||
PLAYLIST_REORDER: PlaylistReorderEventPayload;
|
PLAYLIST_REORDER: PlaylistReorderEventPayload;
|
||||||
QUEUE_RESTORED: QueueRestoredEventPayload;
|
QUEUE_RESTORED: QueueRestoredEventPayload;
|
||||||
|
TAG_EDITED: TagEditedEventPayload;
|
||||||
USER_FAVORITE: UserFavoriteEventPayload;
|
USER_FAVORITE: UserFavoriteEventPayload;
|
||||||
USER_RATING: UserRatingEventPayload;
|
USER_RATING: UserRatingEventPayload;
|
||||||
};
|
};
|
||||||
@@ -79,6 +80,12 @@ export type QueueRestoredEventPayload = {
|
|||||||
position: number;
|
position: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TagEditedEventPayload = {
|
||||||
|
id: string[];
|
||||||
|
itemType: LibraryItem;
|
||||||
|
serverId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserFavoriteEventPayload = {
|
export type UserFavoriteEventPayload = {
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
id: string[];
|
id: string[];
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ export const sharedQueries = {
|
|||||||
...args.options,
|
...args.options,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
scanStatus: (args: QueryHookArgs<null>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: ({ signal }) => {
|
||||||
|
return api.controller.getScanStatus({
|
||||||
|
apiClientProps: { serverId: args.serverId, signal },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
queryKey: queryKeys.server.scanStatus(args.serverId),
|
||||||
|
...args.options,
|
||||||
|
});
|
||||||
|
},
|
||||||
tagList: (args: QueryHookArgs<TagListQuery>) => {
|
tagList: (args: QueryHookArgs<TagListQuery>) => {
|
||||||
return queryOptions({
|
return queryOptions({
|
||||||
gcTime: 1000 * 60 * 24,
|
gcTime: 1000 * 60 * 24,
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import type { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||||
|
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
||||||
|
import { useCurrentServerId } from '/@/renderer/store';
|
||||||
|
|
||||||
|
const SCAN_STATUS_POLL_INTERVAL_MS = 2000;
|
||||||
|
const SCAN_WATCH_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
let isWatchingScan = false;
|
||||||
|
let hasSeenScanStart = false;
|
||||||
|
let watchTimeoutId: null | ReturnType<typeof setTimeout> = null;
|
||||||
|
const watchListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
const subscribeWatchingScan = (listener: () => void) => {
|
||||||
|
watchListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
watchListeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIsWatchingScan = () => isWatchingScan;
|
||||||
|
|
||||||
|
const setIsWatchingScan = (value: boolean) => {
|
||||||
|
if (isWatchingScan === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isWatchingScan = value;
|
||||||
|
for (const listener of watchListeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearWatchTimeout = () => {
|
||||||
|
if (watchTimeoutId !== null) {
|
||||||
|
clearTimeout(watchTimeoutId);
|
||||||
|
watchTimeoutId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopWatchingScan = () => {
|
||||||
|
clearWatchTimeout();
|
||||||
|
hasSeenScanStart = false;
|
||||||
|
setIsWatchingScan(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startScanWatch = () => {
|
||||||
|
clearWatchTimeout();
|
||||||
|
hasSeenScanStart = false;
|
||||||
|
setIsWatchingScan(true);
|
||||||
|
watchTimeoutId = setTimeout(() => {
|
||||||
|
watchTimeoutId = null;
|
||||||
|
if (isWatchingScan && !hasSeenScanStart) {
|
||||||
|
stopWatchingScan();
|
||||||
|
}
|
||||||
|
}, SCAN_WATCH_TIMEOUT_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidateLibraryQueriesAfterScan = (queryClient: QueryClient, serverId: string) => {
|
||||||
|
return Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.songs.root(serverId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.albums.root(serverId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.albumArtists.root(serverId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.artists.root(serverId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.genres.root(serverId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: [serverId, 'folders'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.musicFolders.list(serverId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.search.root(serverId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: [serverId, 'tags'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.roles.list(serverId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.playlists.root(serverId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: [serverId, 'home'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['home'] }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishScanWatch = (queryClient: QueryClient, serverId: string) => {
|
||||||
|
if (!hasSeenScanStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearWatchTimeout();
|
||||||
|
hasSeenScanStart = false;
|
||||||
|
setIsWatchingScan(false);
|
||||||
|
invalidateLibraryQueriesAfterScan(queryClient, serverId);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventEmitter.on('TAG_EDITED', () => {
|
||||||
|
startScanWatch();
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useScanStatus = () => {
|
||||||
|
const serverId = useCurrentServerId();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isWatching = useSyncExternalStore(subscribeWatchingScan, getIsWatchingScan);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
...sharedQueries.scanStatus({
|
||||||
|
options: {
|
||||||
|
enabled: isWatching && Boolean(serverId),
|
||||||
|
refetchInterval: SCAN_STATUS_POLL_INTERVAL_MS,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 0,
|
||||||
|
throwOnError: false,
|
||||||
|
},
|
||||||
|
query: null,
|
||||||
|
serverId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWatching || !query.isFetched || query.isFetching || !query.data || !serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.data.scanning) {
|
||||||
|
hasSeenScanStart = true;
|
||||||
|
clearWatchTimeout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep polling until the scan has actually started, then stop when it finishes.
|
||||||
|
if (hasSeenScanStart && !query.data.scanning) {
|
||||||
|
finishScanWatch(queryClient, serverId);
|
||||||
|
}
|
||||||
|
}, [isWatching, query.data, query.isFetched, query.isFetching, queryClient, serverId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
isScanning: query.data?.scanning === true,
|
||||||
|
isWatching,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
|
|||||||
|
|
||||||
import styles from './action-bar.module.css';
|
import styles from './action-bar.module.css';
|
||||||
|
|
||||||
|
import { useScanStatus } from '/@/renderer/features/shared/hooks/use-scan-status';
|
||||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||||
import { useCommandPalette } from '/@/renderer/store';
|
import { useCommandPalette } from '/@/renderer/store';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
@@ -15,6 +16,7 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
|
|||||||
export const ActionBar = () => {
|
export const ActionBar = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { open } = useCommandPalette();
|
const { open } = useCommandPalette();
|
||||||
|
const { isScanning } = useScanStatus();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -49,7 +51,11 @@ export const ActionBar = () => {
|
|||||||
<DropdownMenu position="bottom-start">
|
<DropdownMenu position="bottom-start">
|
||||||
<DropdownMenu.Target>
|
<DropdownMenu.Target>
|
||||||
<Button p="0">
|
<Button p="0">
|
||||||
<Icon icon="menu" size="lg" />
|
<Icon
|
||||||
|
animate={isScanning ? 'spin' : undefined}
|
||||||
|
icon={isScanning ? 'spinner' : 'menu'}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenu.Target>
|
</DropdownMenu.Target>
|
||||||
<DropdownMenu.Dropdown>
|
<DropdownMenu.Dropdown>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Link, NavLink, useNavigate } from 'react-router';
|
|||||||
|
|
||||||
import styles from './collapsed-sidebar.module.css';
|
import styles from './collapsed-sidebar.module.css';
|
||||||
|
|
||||||
|
import { useScanStatus } from '/@/renderer/features/shared/hooks/use-scan-status';
|
||||||
import { CollapsedSidebarButton } from '/@/renderer/features/sidebar/components/collapsed-sidebar-button';
|
import { CollapsedSidebarButton } from '/@/renderer/features/sidebar/components/collapsed-sidebar-button';
|
||||||
import { CollapsedSidebarItem } from '/@/renderer/features/sidebar/components/collapsed-sidebar-item';
|
import { CollapsedSidebarItem } from '/@/renderer/features/sidebar/components/collapsed-sidebar-item';
|
||||||
import { getCollectionTo } from '/@/renderer/features/sidebar/components/sidebar-collection-list';
|
import { getCollectionTo } from '/@/renderer/features/sidebar/components/sidebar-collection-list';
|
||||||
@@ -35,6 +36,8 @@ export const CollapsedSidebar = () => {
|
|||||||
const { windowBarStyle } = useWindowSettings();
|
const { windowBarStyle } = useWindowSettings();
|
||||||
const sidebarCollapsedNavigation = useSidebarCollapsedNavigation();
|
const sidebarCollapsedNavigation = useSidebarCollapsedNavigation();
|
||||||
const sidebarItems = useSidebarItems();
|
const sidebarItems = useSidebarItems();
|
||||||
|
const { isScanning } = useScanStatus();
|
||||||
|
|
||||||
const translatedSidebarItemMap = useMemo(
|
const translatedSidebarItemMap = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
Albums: t('page.sidebar.albums'),
|
Albums: t('page.sidebar.albums'),
|
||||||
@@ -94,7 +97,14 @@ export const CollapsedSidebar = () => {
|
|||||||
<CollapsedSidebarItem
|
<CollapsedSidebarItem
|
||||||
activeIcon={null}
|
activeIcon={null}
|
||||||
component={Flex}
|
component={Flex}
|
||||||
icon={<Icon fill="muted" icon="menu" size="3xl" />}
|
icon={
|
||||||
|
<Icon
|
||||||
|
animate={isScanning ? 'spin' : undefined}
|
||||||
|
fill="muted"
|
||||||
|
icon={isScanning ? 'spinner' : 'menu'}
|
||||||
|
size="3xl"
|
||||||
|
/>
|
||||||
|
}
|
||||||
label={t('common.menu')}
|
label={t('common.menu')}
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
|
|||||||
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
|
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
|
||||||
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
|
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
|
||||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
||||||
|
import { useScanStatus } from '/@/renderer/features/shared/hooks/use-scan-status';
|
||||||
import { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items';
|
import { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
import { hasFeature } from '/@/shared/api/utils';
|
import { hasFeature } from '/@/shared/api/utils';
|
||||||
@@ -23,6 +24,7 @@ import { ServerFeature } from '/@/shared/types/features-types';
|
|||||||
export const ServerSelector = () => {
|
export const ServerSelector = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentServer = useCurrentServer();
|
const currentServer = useCurrentServer();
|
||||||
|
const { data: scanStatus, isScanning, isWatching } = useScanStatus();
|
||||||
|
|
||||||
const { data: musicFolders } = useQuery(
|
const { data: musicFolders } = useQuery(
|
||||||
currentServer
|
currentServer
|
||||||
@@ -54,6 +56,19 @@ export const ServerSelector = () => {
|
|||||||
return selectedMusicFolders[0].name;
|
return selectedMusicFolders[0].name;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const scanProgressParts: string[] = [];
|
||||||
|
if (scanStatus?.count && scanStatus.count > 0) {
|
||||||
|
scanProgressParts.push(t('common.scanItemCount', { count: scanStatus.count }));
|
||||||
|
}
|
||||||
|
if (scanStatus?.folderCount && scanStatus.folderCount > 0) {
|
||||||
|
scanProgressParts.push(t('common.scanFolderCount', { count: scanStatus.folderCount }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanStatusText =
|
||||||
|
isWatching && isScanning
|
||||||
|
? [t('common.scanningLibrary'), ...scanProgressParts].filter(Boolean).join(' · ')
|
||||||
|
: null;
|
||||||
|
|
||||||
const logo =
|
const logo =
|
||||||
currentServer.type === ServerType.NAVIDROME
|
currentServer.type === ServerType.NAVIDROME
|
||||||
? NavidromeLogo
|
? NavidromeLogo
|
||||||
@@ -75,6 +90,11 @@ export const ServerSelector = () => {
|
|||||||
<Text isMuted size="xs" truncate>
|
<Text isMuted size="xs" truncate>
|
||||||
{musicFolderDisplayText}
|
{musicFolderDisplayText}
|
||||||
</Text>
|
</Text>
|
||||||
|
{scanStatusText && (
|
||||||
|
<Text isMuted size="xs" truncate>
|
||||||
|
{scanStatusText}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Icon icon="ellipsisVertical" size="sm" />
|
<Icon icon="ellipsisVertical" size="sm" />
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import { FIELD_PRIORITY, KNOWN_TAG_MAP, KNOWN_TAGS, type KnownTag } from '../uti
|
|||||||
import { base64ToBytes, bytesToBase64, formatBatchFileErrors } from '../utils/utils';
|
import { base64ToBytes, bytesToBase64, formatBatchFileErrors } from '../utils/utils';
|
||||||
|
|
||||||
import { controller } from '/@/renderer/api/controller';
|
import { controller } from '/@/renderer/api/controller';
|
||||||
|
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||||
import { useCurrentServer, useSettingsStoreActions, useTagEditorSettings } from '/@/renderer/store';
|
import { useCurrentServer, useSettingsStoreActions, useTagEditorSettings } from '/@/renderer/store';
|
||||||
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
|
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { Song } from '/@/shared/types/domain-types';
|
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export const EDIT_SCOPE_ALL = '__all__';
|
export const EDIT_SCOPE_ALL = '__all__';
|
||||||
|
|
||||||
@@ -493,6 +494,14 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
eventEmitter.emit('TAG_EDITED', {
|
||||||
|
id: songsInScope.map((s) => s.id),
|
||||||
|
itemType: LibraryItem.SONG,
|
||||||
|
serverId: server.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (close) {
|
if (close) {
|
||||||
closeAllModals();
|
closeAllModals();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -760,6 +760,40 @@ const serverInfo = z.object({
|
|||||||
Version: z.string(),
|
Version: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const taskTriggerInfo = z.object({
|
||||||
|
DayOfWeek: z.string().nullish(),
|
||||||
|
IntervalTicks: z.number().nullish(),
|
||||||
|
MaxRuntimeTicks: z.number().nullish(),
|
||||||
|
TimeOfDayTicks: z.number().nullish(),
|
||||||
|
Type: z.string().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskResult = z.object({
|
||||||
|
EndTimeUtc: z.string().nullish(),
|
||||||
|
ErrorMessage: z.string().nullish(),
|
||||||
|
Id: z.string().nullish(),
|
||||||
|
Key: z.string().nullish(),
|
||||||
|
LongErrorMessage: z.string().nullish(),
|
||||||
|
Name: z.string().nullish(),
|
||||||
|
StartTimeUtc: z.string().nullish(),
|
||||||
|
Status: z.string().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskInfo = z.object({
|
||||||
|
Category: z.string().nullish(),
|
||||||
|
CurrentProgressPercentage: z.number().nullish(),
|
||||||
|
Description: z.string().nullish(),
|
||||||
|
Id: z.string().nullish(),
|
||||||
|
IsHidden: z.boolean().optional(),
|
||||||
|
Key: z.string().nullish(),
|
||||||
|
LastExecutionResult: taskResult.nullish(),
|
||||||
|
Name: z.string().nullish(),
|
||||||
|
State: z.enum(['Idle', 'Cancelling', 'Running']).optional(),
|
||||||
|
Triggers: z.array(taskTriggerInfo).nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduledTasks = z.array(taskInfo);
|
||||||
|
|
||||||
const similarSongsParameters = z.object({
|
const similarSongsParameters = z.object({
|
||||||
Fields: z.array(z.string()).readonly().optional(),
|
Fields: z.array(z.string()).readonly().optional(),
|
||||||
Limit: z.number().optional(),
|
Limit: z.number().optional(),
|
||||||
@@ -914,6 +948,7 @@ export const jfType = {
|
|||||||
playlistList,
|
playlistList,
|
||||||
playlistSongList,
|
playlistSongList,
|
||||||
removeFromPlaylist,
|
removeFromPlaylist,
|
||||||
|
scheduledTasks,
|
||||||
scrobble,
|
scrobble,
|
||||||
search,
|
search,
|
||||||
serverInfo,
|
serverInfo,
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ img.size-5xl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spin {
|
.spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1.5s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse {
|
.pulse {
|
||||||
|
|||||||
@@ -1332,6 +1332,10 @@ export type FullLyricsMetadata = Omit<InternetProviderLyricResponse, 'id' | 'lyr
|
|||||||
source: string;
|
source: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GetScanStatusArgs = BaseEndpointArgs;
|
||||||
|
|
||||||
|
export type GetScanStatusResponse = ScanStatus;
|
||||||
|
|
||||||
export type InternetProviderLyricResponse = {
|
export type InternetProviderLyricResponse = {
|
||||||
artist: string;
|
artist: string;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1388,6 +1392,13 @@ export type RefreshItemsArgs = BaseEndpointArgs & { query: { ids: string[] } };
|
|||||||
|
|
||||||
export type RefreshItemsResponse = null;
|
export type RefreshItemsResponse = null;
|
||||||
|
|
||||||
|
export type ScanStatus = {
|
||||||
|
count: number;
|
||||||
|
folderCount: number;
|
||||||
|
lastScan?: string;
|
||||||
|
scanning: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ScrobbleArgs = BaseEndpointArgs & {
|
export type ScrobbleArgs = BaseEndpointArgs & {
|
||||||
query: ScrobbleQuery;
|
query: ScrobbleQuery;
|
||||||
};
|
};
|
||||||
@@ -1568,6 +1579,7 @@ export type ControllerEndpoint = {
|
|||||||
getPlayQueue: (args: GetQueueArgs) => Promise<GetQueueResponse>;
|
getPlayQueue: (args: GetQueueArgs) => Promise<GetQueueResponse>;
|
||||||
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
||||||
getRoles: (args: BaseEndpointArgs) => Promise<Array<string | { label: string; value: string }>>;
|
getRoles: (args: BaseEndpointArgs) => Promise<Array<string | { label: string; value: string }>>;
|
||||||
|
getScanStatus: (args: GetScanStatusArgs) => Promise<GetScanStatusResponse>;
|
||||||
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
|
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
|
||||||
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
|
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
|
||||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||||
@@ -1733,6 +1745,9 @@ export type InternalControllerEndpoint = {
|
|||||||
getRoles: (
|
getRoles: (
|
||||||
args: ReplaceApiClientProps<BaseEndpointArgs>,
|
args: ReplaceApiClientProps<BaseEndpointArgs>,
|
||||||
) => Promise<Array<string | { label: string; value: string }>>;
|
) => Promise<Array<string | { label: string; value: string }>>;
|
||||||
|
getScanStatus: (
|
||||||
|
args: ReplaceApiClientProps<GetScanStatusArgs>,
|
||||||
|
) => Promise<GetScanStatusResponse>;
|
||||||
getServerInfo: (args: ReplaceApiClientProps<ServerInfoArgs>) => Promise<ServerInfo>;
|
getServerInfo: (args: ReplaceApiClientProps<ServerInfoArgs>) => Promise<ServerInfo>;
|
||||||
getSimilarSongs: (args: ReplaceApiClientProps<SimilarSongsArgs>) => Promise<Song[]>;
|
getSimilarSongs: (args: ReplaceApiClientProps<SimilarSongsArgs>) => Promise<Song[]>;
|
||||||
getSongDetail: (args: ReplaceApiClientProps<SongDetailArgs>) => Promise<SongDetailResponse>;
|
getSongDetail: (args: ReplaceApiClientProps<SongDetailArgs>) => Promise<SongDetailResponse>;
|
||||||
|
|||||||
Reference in New Issue
Block a user