diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4cd4fe0c5..fd74b27b0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -173,7 +173,11 @@ "gridRows": "Grid rows", "tableColumns": "Table columns", "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": { "album_one": "Album", diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 9027a270e..a015ecce5 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -612,6 +612,18 @@ export const controller: GeneralController = { server.type, )?.(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) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 921a1843e..7136f7af2 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -206,6 +206,14 @@ export const contract = c.router({ 400: jfType._response.error, }, }, + getScheduledTasks: { + method: 'GET', + path: 'ScheduledTasks', + responses: { + 200: jfType._response.scheduledTasks, + 400: jfType._response.error, + }, + }, getServerInfo: { method: 'GET', path: 'system/info', diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 3a7a5e998..e957fb006 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -1161,6 +1161,34 @@ export const JellyfinController: InternalControllerEndpoint = { }; }, 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) => { const { apiClientProps } = args; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index dd76d74ce..b9642a69d 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -723,6 +723,7 @@ export const NavidromeController: InternalControllerEndpoint = { getRandomSongList: SubsonicController.getRandomSongList, getRoles: async ({ apiClientProps }) => hasFeature(apiClientProps.server, ServerFeature.BFR) ? NAVIDROME_ROLES : [], + getScanStatus: SubsonicController.getScanStatus, getServerInfo: async (args) => { const { apiClientProps } = args; diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 94ac40302..686a62c74 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -363,6 +363,7 @@ export const queryKeys: Record< }, server: { root: (serverId: string) => [serverId] as const, + scanStatus: (serverId: string) => [serverId, 'server', 'scanStatus'] as const, }, songs: { albumRadio: (serverId: string, query?: AlbumRadioQuery) => { diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 529e6aaee..34701a62f 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1355,6 +1355,22 @@ export const SubsonicController: InternalControllerEndpoint = { final.splice(0, 0, { label: 'all artists', value: '' }); 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) => { const { apiClientProps } = args; diff --git a/src/renderer/events/events.ts b/src/renderer/events/events.ts index 875a81d96..caeac14d2 100644 --- a/src/renderer/events/events.ts +++ b/src/renderer/events/events.ts @@ -20,6 +20,7 @@ export type EventMap = { PLAYLIST_MOVE_UP: PlaylistMoveEventPayload; PLAYLIST_REORDER: PlaylistReorderEventPayload; QUEUE_RESTORED: QueueRestoredEventPayload; + TAG_EDITED: TagEditedEventPayload; USER_FAVORITE: UserFavoriteEventPayload; USER_RATING: UserRatingEventPayload; }; @@ -79,6 +80,12 @@ export type QueueRestoredEventPayload = { position: number; }; +export type TagEditedEventPayload = { + id: string[]; + itemType: LibraryItem; + serverId: string; +}; + export type UserFavoriteEventPayload = { favorite: boolean; id: string[]; diff --git a/src/renderer/features/shared/api/shared-api.ts b/src/renderer/features/shared/api/shared-api.ts index 6b2ea7d60..12177c908 100644 --- a/src/renderer/features/shared/api/shared-api.ts +++ b/src/renderer/features/shared/api/shared-api.ts @@ -28,6 +28,17 @@ export const sharedQueries = { ...args.options, }); }, + scanStatus: (args: QueryHookArgs) => { + return queryOptions({ + queryFn: ({ signal }) => { + return api.controller.getScanStatus({ + apiClientProps: { serverId: args.serverId, signal }, + }); + }, + queryKey: queryKeys.server.scanStatus(args.serverId), + ...args.options, + }); + }, tagList: (args: QueryHookArgs) => { return queryOptions({ gcTime: 1000 * 60 * 24, diff --git a/src/renderer/features/shared/hooks/use-scan-status.ts b/src/renderer/features/shared/hooks/use-scan-status.ts new file mode 100644 index 000000000..45dd96e2e --- /dev/null +++ b/src/renderer/features/shared/hooks/use-scan-status.ts @@ -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 = 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, + }; +}; diff --git a/src/renderer/features/sidebar/components/action-bar.tsx b/src/renderer/features/sidebar/components/action-bar.tsx index 443df98d7..dfcbe04e6 100644 --- a/src/renderer/features/sidebar/components/action-bar.tsx +++ b/src/renderer/features/sidebar/components/action-bar.tsx @@ -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 (
@@ -49,7 +51,11 @@ export const ActionBar = () => { diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx index 05d0c7a64..bfcc6d994 100644 --- a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx +++ b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx @@ -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 = () => { } + icon={ + + } label={t('common.menu')} style={{ cursor: 'pointer', diff --git a/src/renderer/features/sidebar/components/server-selector.tsx b/src/renderer/features/sidebar/components/server-selector.tsx index 1211e9c09..b3d055198 100644 --- a/src/renderer/features/sidebar/components/server-selector.tsx +++ b/src/renderer/features/sidebar/components/server-selector.tsx @@ -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 = () => { {musicFolderDisplayText} + {scanStatusText && ( + + {scanStatusText} + + )} diff --git a/src/renderer/features/tag-editor/hooks/use-metadata-editor.ts b/src/renderer/features/tag-editor/hooks/use-metadata-editor.ts index 53a2ec8a7..5ecca9fd5 100644 --- a/src/renderer/features/tag-editor/hooks/use-metadata-editor.ts +++ b/src/renderer/features/tag-editor/hooks/use-metadata-editor.ts @@ -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; diff --git a/src/shared/api/jellyfin/jellyfin-types.ts b/src/shared/api/jellyfin/jellyfin-types.ts index e2e6ba2f3..1d475a432 100644 --- a/src/shared/api/jellyfin/jellyfin-types.ts +++ b/src/shared/api/jellyfin/jellyfin-types.ts @@ -760,6 +760,40 @@ const serverInfo = z.object({ 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({ Fields: z.array(z.string()).readonly().optional(), Limit: z.number().optional(), @@ -914,6 +948,7 @@ export const jfType = { playlistList, playlistSongList, removeFromPlaylist, + scheduledTasks, scrobble, search, serverInfo, diff --git a/src/shared/components/icon/icon.module.css b/src/shared/components/icon/icon.module.css index 241d243b9..18be6dca4 100644 --- a/src/shared/components/icon/icon.module.css +++ b/src/shared/components/icon/icon.module.css @@ -132,7 +132,7 @@ img.size-5xl { } .spin { - animation: spin 1s linear infinite; + animation: spin 1.5s linear infinite; } .pulse { diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index f9d275a9f..ccf9a63e6 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1332,6 +1332,10 @@ export type FullLyricsMetadata = Omit Promise; getRandomSongList: (args: RandomSongListArgs) => Promise; getRoles: (args: BaseEndpointArgs) => Promise>; + getScanStatus: (args: GetScanStatusArgs) => Promise; getServerInfo: (args: ServerInfoArgs) => Promise; getSimilarSongs: (args: SimilarSongsArgs) => Promise; getSongDetail: (args: SongDetailArgs) => Promise; @@ -1733,6 +1745,9 @@ export type InternalControllerEndpoint = { getRoles: ( args: ReplaceApiClientProps, ) => Promise>; + getScanStatus: ( + args: ReplaceApiClientProps, + ) => Promise; getServerInfo: (args: ReplaceApiClientProps) => Promise; getSimilarSongs: (args: ReplaceApiClientProps) => Promise; getSongDetail: (args: ReplaceApiClientProps) => Promise;