diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index b2e0743cc..b1440ff59 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -506,6 +506,7 @@
"goBack": "Go back",
"goForward": "Go forward",
"manageServers": "Manage servers",
+ "rescanLibrary": "Rescan library",
"logout": "Logout",
"privateModeOff": "Turn off private mode",
"privateModeOn": "Turn on private mode",
diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts
index c0cc1dee5..4ea6b8a3b 100644
--- a/src/renderer/api/controller.ts
+++ b/src/renderer/api/controller.ts
@@ -1046,6 +1046,18 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
+ startLibraryScan(args) {
+ const server = getServerById(args.apiClientProps.serverId);
+
+ if (!server) {
+ throw new Error(`${i18n.t('error.apiRouteError')}: startLibraryScan`);
+ }
+
+ return apiController(
+ 'startLibraryScan',
+ server.type,
+ )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
+ },
updateInternetRadioStation(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 7136f7af2..79b50acc8 100644
--- a/src/renderer/api/jellyfin/jellyfin-api.ts
+++ b/src/renderer/api/jellyfin/jellyfin-api.ts
@@ -314,7 +314,12 @@ export const contract = c.router({
method: 'POST',
path: 'Items/:id/Refresh',
query: z.object({
+ ImageRefreshMode: z.string().optional(),
MetadataRefreshMode: z.string().optional(),
+ Recursive: z.boolean().optional(),
+ RegenerateTrickplay: z.boolean().optional(),
+ ReplaceAllImages: z.boolean().optional(),
+ ReplaceAllMetadata: z.boolean().optional(),
}),
responses: {
204: z.null(),
diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts
index e957fb006..4cb24c69d 100644
--- a/src/renderer/api/jellyfin/jellyfin-controller.ts
+++ b/src/renderer/api/jellyfin/jellyfin-controller.ts
@@ -1917,6 +1917,54 @@ export const JellyfinController: InternalControllerEndpoint = {
return null;
},
+ startLibraryScan: async (args) => {
+ const { apiClientProps } = args;
+ const server = apiClientProps.server;
+ const userId = server?.userId;
+
+ if (!userId) {
+ throw new Error('No userId found');
+ }
+
+ let musicFolderIds = server.musicFolderId?.filter(Boolean) ?? [];
+
+ if (musicFolderIds.length === 0) {
+ const res = await jfApiClient(apiClientProps).getMusicFolderList({
+ params: { userId },
+ });
+
+ if (res.status !== 200) {
+ throw new Error('Failed to get music folders');
+ }
+
+ musicFolderIds = res.body.Items.filter(
+ (folder) => folder.CollectionType === jfType._enum.collection.MUSIC,
+ ).map((folder) => folder.Id);
+ }
+
+ if (musicFolderIds.length === 0) {
+ throw new Error('No music folders found');
+ }
+
+ await Promise.all(
+ musicFolderIds.map((id) =>
+ jfApiClient(apiClientProps).refreshItem({
+ body: null,
+ params: { id },
+ query: {
+ ImageRefreshMode: 'Default',
+ MetadataRefreshMode: 'Default',
+ Recursive: true,
+ RegenerateTrickplay: false,
+ ReplaceAllImages: false,
+ ReplaceAllMetadata: false,
+ },
+ }),
+ ),
+ );
+
+ return null;
+ },
updateInternetRadioStation: async (args) => {
const { apiClientProps, body, query } = args;
diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts
index b9642a69d..4788159a2 100644
--- a/src/renderer/api/navidrome/navidrome-controller.ts
+++ b/src/renderer/api/navidrome/navidrome-controller.ts
@@ -1187,6 +1187,7 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id,
};
},
+ startLibraryScan: SubsonicController.startLibraryScan,
updateInternetRadioStation: async (args) => {
const { apiClientProps, body, query } = args;
diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts
index 572908bb6..a835c3a07 100644
--- a/src/renderer/api/subsonic/subsonic-controller.ts
+++ b/src/renderer/api/subsonic/subsonic-controller.ts
@@ -2366,6 +2366,17 @@ export const SubsonicController: InternalControllerEndpoint = {
return null;
},
+ startLibraryScan: async (args) => {
+ const { apiClientProps } = args;
+
+ const res = await ssApiClient(apiClientProps).startScan({ query: {} });
+
+ if (res.status !== 200) {
+ throw new Error('Failed to start library scan');
+ }
+
+ return null;
+ },
updateInternetRadioStation: async (args) => {
const { apiClientProps, body, query } = args;
diff --git a/src/renderer/features/shared/hooks/use-scan-status.ts b/src/renderer/features/shared/hooks/use-scan-status.ts
index 45dd96e2e..e5a70cc91 100644
--- a/src/renderer/features/shared/hooks/use-scan-status.ts
+++ b/src/renderer/features/shared/hooks/use-scan-status.ts
@@ -49,7 +49,7 @@ const stopWatchingScan = () => {
setIsWatchingScan(false);
};
-const startScanWatch = () => {
+export const startScanWatch = () => {
clearWatchTimeout();
hasSeenScanStart = false;
setIsWatchingScan(true);
diff --git a/src/renderer/features/sidebar/components/server-selector-items.tsx b/src/renderer/features/sidebar/components/server-selector-items.tsx
index 1a574f1e7..2e6ecd8ee 100644
--- a/src/renderer/features/sidebar/components/server-selector-items.tsx
+++ b/src/renderer/features/sidebar/components/server-selector-items.tsx
@@ -4,6 +4,7 @@ import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
+import { controller } from '/@/renderer/api/controller';
import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';
import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
@@ -11,11 +12,13 @@ import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.p
import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form';
import { ServerList } from '/@/renderer/features/servers/components/server-list';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
+import { startScanWatch, useScanStatus } from '/@/renderer/features/shared/hooks/use-scan-status';
import { AppRoute } from '/@/renderer/router/routes';
import { useAuthStoreActions, useCurrentServer, useServerList } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Icon } from '/@/shared/components/icon/icon';
+import { toast } from '/@/shared/components/toast/toast';
import {
ServerListItem,
ServerListItemWithCredential,
@@ -31,6 +34,7 @@ export const ServerSelectorItems = () => {
const currentServer = useCurrentServer();
const serverList = useServerList();
const { logout, setCurrentServer, setMusicFolderId } = useAuthStoreActions();
+ const { isScanning, isWatching } = useScanStatus();
const { data: musicFolders } = useQuery(
currentServer
@@ -137,6 +141,23 @@ export const ServerSelectorItems = () => {
}, 0);
};
+ const handleRescanLibrary = async () => {
+ if (!currentServer || isWatching || isScanning) {
+ return;
+ }
+
+ try {
+ await controller.startLibraryScan({
+ apiClientProps: { serverId: currentServer.id },
+ });
+ startScanWatch();
+ } catch (err) {
+ toast.error({
+ message: err instanceof Error ? err.message : String(err),
+ });
+ }
+ };
+
return (
<>
{t('page.appMenu.selectServer')}
@@ -184,6 +205,12 @@ export const ServerSelectorItems = () => {
>
{t('page.appMenu.manageServers')}
+ }
+ onClick={handleRescanLibrary}
+ >
+ {t('page.appMenu.rescanLibrary')}
+
}
onClick={handleLogout}
diff --git a/src/renderer/features/titlebar/components/app-menu.tsx b/src/renderer/features/titlebar/components/app-menu.tsx
index eb09c0e3d..5825712a2 100644
--- a/src/renderer/features/titlebar/components/app-menu.tsx
+++ b/src/renderer/features/titlebar/components/app-menu.tsx
@@ -1,4 +1,3 @@
-import { openModal } from '@mantine/modals';
import isElectron from 'is-electron';
import { Fragment, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
@@ -6,8 +5,6 @@ import { Link, useNavigate } from 'react-router';
import styles from './app-menu.module.css';
-import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';
-import { ServerList } from '/@/renderer/features/servers/components/server-list';
import { UpdateAvailableButton } from '/@/renderer/features/settings/components/update-available-button';
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
@@ -120,13 +117,6 @@ export const AppMenu = () => {
});
};
- const handleManageServersModal = () => {
- openModal({
- children: ,
- title: t('page.manageServers.title'),
- });
- };
-
const handleQuit = () => {
browser?.quit();
};
@@ -222,21 +212,6 @@ export const AppMenu = () => {
id: 'divider-2',
type: 'divider',
},
- {
- condition: !isServerLock(),
- id: 'manage-servers',
- item: {
- label: t('page.appMenu.manageServers'),
- leftSection: ,
- onClick: handleManageServersModal,
- type: 'item',
- },
- type: 'conditional-item',
- },
- {
- id: 'divider-3',
- type: 'divider',
- },
{
icon: 'settings',
id: 'settings',
diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts
index ccf9a63e6..74d099090 100644
--- a/src/shared/types/domain-types.ts
+++ b/src/shared/types/domain-types.ts
@@ -1124,6 +1124,10 @@ export type ShareItemBody = {
// Sharing
export type ShareItemResponse = undefined | { id: string };
+export type StartLibraryScanArgs = BaseEndpointArgs;
+
+export type StartLibraryScanResponse = null;
+
export type UpdateInternetRadioStationArgs = BaseEndpointArgs & {
body: UpdateInternetRadioStationBody;
query: UpdateInternetRadioStationQuery;
@@ -1602,6 +1606,7 @@ export type ControllerEndpoint = {
setPlaylistSongs: (args: SetPlaylistSongsArgs) => Promise;
setRating?: (args: SetRatingArgs) => Promise;
shareItem?: (args: ShareItemArgs) => Promise;
+ startLibraryScan: (args: StartLibraryScanArgs) => Promise;
updateInternetRadioStation: (
args: UpdateInternetRadioStationArgs,
) => Promise;
@@ -1780,6 +1785,9 @@ export type InternalControllerEndpoint = {
) => Promise;
setRating?: (args: ReplaceApiClientProps) => Promise;
shareItem?: (args: ReplaceApiClientProps) => Promise;
+ startLibraryScan: (
+ args: ReplaceApiClientProps,
+ ) => Promise;
updateInternetRadioStation: (
args: ReplaceApiClientProps,
) => Promise;