mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-08 05:12:57 +02:00
add scan listener and query invalidation on tag editor save
This commit is contained in:
@@ -28,6 +28,17 @@ export const sharedQueries = {
|
||||
...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>) => {
|
||||
return queryOptions({
|
||||
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 { useScanStatus } from '/@/renderer/features/shared/hooks/use-scan-status';
|
||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||
import { useCommandPalette } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
@@ -15,6 +16,7 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
export const ActionBar = () => {
|
||||
const { t } = useTranslation();
|
||||
const { open } = useCommandPalette();
|
||||
const { isScanning } = useScanStatus();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -49,7 +51,11 @@ export const ActionBar = () => {
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button p="0">
|
||||
<Icon icon="menu" size="lg" />
|
||||
<Icon
|
||||
animate={isScanning ? 'spin' : undefined}
|
||||
icon={isScanning ? 'spinner' : 'menu'}
|
||||
size="lg"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Link, NavLink, useNavigate } from 'react-router';
|
||||
|
||||
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 { CollapsedSidebarItem } from '/@/renderer/features/sidebar/components/collapsed-sidebar-item';
|
||||
import { getCollectionTo } from '/@/renderer/features/sidebar/components/sidebar-collection-list';
|
||||
@@ -35,6 +36,8 @@ export const CollapsedSidebar = () => {
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const sidebarCollapsedNavigation = useSidebarCollapsedNavigation();
|
||||
const sidebarItems = useSidebarItems();
|
||||
const { isScanning } = useScanStatus();
|
||||
|
||||
const translatedSidebarItemMap = useMemo(
|
||||
() => ({
|
||||
Albums: t('page.sidebar.albums'),
|
||||
@@ -94,7 +97,14 @@ export const CollapsedSidebar = () => {
|
||||
<CollapsedSidebarItem
|
||||
activeIcon={null}
|
||||
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')}
|
||||
style={{
|
||||
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 OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
|
||||
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 { useCurrentServer } from '/@/renderer/store';
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
@@ -23,6 +24,7 @@ import { ServerFeature } from '/@/shared/types/features-types';
|
||||
export const ServerSelector = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentServer = useCurrentServer();
|
||||
const { data: scanStatus, isScanning, isWatching } = useScanStatus();
|
||||
|
||||
const { data: musicFolders } = useQuery(
|
||||
currentServer
|
||||
@@ -54,6 +56,19 @@ export const ServerSelector = () => {
|
||||
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 =
|
||||
currentServer.type === ServerType.NAVIDROME
|
||||
? NavidromeLogo
|
||||
@@ -75,6 +90,11 @@ export const ServerSelector = () => {
|
||||
<Text isMuted size="xs" truncate>
|
||||
{musicFolderDisplayText}
|
||||
</Text>
|
||||
{scanStatusText && (
|
||||
<Text isMuted size="xs" truncate>
|
||||
{scanStatusText}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Icon icon="ellipsisVertical" size="sm" />
|
||||
</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 { controller } from '/@/renderer/api/controller';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { useCurrentServer, useSettingsStoreActions, useTagEditorSettings } from '/@/renderer/store';
|
||||
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
|
||||
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__';
|
||||
|
||||
@@ -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) {
|
||||
closeAllModals();
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user