From ef51672459125e58a3abb375f8c519e9d7a2a1b8 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Wed, 27 Sep 2023 21:14:13 -0700 Subject: [PATCH 1/3] add server refresh --- src/renderer/api/controller.ts | 28 +++ src/renderer/api/jellyfin/jellyfin-api.ts | 9 + .../api/jellyfin/jellyfin-controller.ts | 21 ++ src/renderer/api/subsonic/subsonic-api.ts | 15 ++ .../api/subsonic/subsonic-controller.ts | 55 +++++ src/renderer/api/subsonic/subsonic-types.ts | 15 ++ src/renderer/api/types.ts | 12 + .../sidebar/components/collapsed-sidebar.tsx | 2 + .../features/sidebar/components/rescan.tsx | 228 ++++++++++++++++++ .../features/sidebar/components/sidebar.tsx | 2 + .../layouts/default-layout/left-sidebar.tsx | 3 +- 11 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 src/renderer/features/sidebar/components/rescan.tsx diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index ea6ce7887..a56bf0941 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -48,6 +48,9 @@ import type { SearchResponse, LyricsArgs, LyricsResponse, + RescanArgs, + ScanStatus, + ScanStatusArgs, } from '/@/renderer/api/types'; import { ServerType } from '/@/renderer/types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types'; @@ -84,11 +87,13 @@ export type ControllerEndpoint = Partial<{ getPlaylistList: (args: PlaylistListArgs) => Promise; getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; getRandomSongList: (args: RandomSongListArgs) => Promise; + getScanStatus: (args: ScanStatusArgs) => Promise; getSongDetail: (args: SongDetailArgs) => Promise; getSongList: (args: SongListArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; getUserList: (args: UserListArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; + rescan: (args: RescanArgs) => Promise; scrobble: (args: ScrobbleArgs) => Promise; search: (args: SearchArgs) => Promise; setRating: (args: SetRatingArgs) => Promise; @@ -128,11 +133,13 @@ const endpoints: ApiController = { getPlaylistList: jfController.getPlaylistList, getPlaylistSongList: jfController.getPlaylistSongList, getRandomSongList: jfController.getRandomSongList, + getScanStatus: undefined, getSongDetail: undefined, getSongList: jfController.getSongList, getTopSongs: jfController.getTopSongList, getUserList: undefined, removeFromPlaylist: jfController.removeFromPlaylist, + rescan: jfController.rescan, scrobble: jfController.scrobble, search: jfController.search, setRating: undefined, @@ -164,11 +171,13 @@ const endpoints: ApiController = { getPlaylistList: ndController.getPlaylistList, getPlaylistSongList: ndController.getPlaylistSongList, getRandomSongList: ssController.getRandomSongList, + getScanStatus: ssController.getScanStatus, getSongDetail: ndController.getSongDetail, getSongList: ndController.getSongList, getTopSongs: ssController.getTopSongList, getUserList: ndController.getUserList, removeFromPlaylist: ndController.removeFromPlaylist, + rescan: ssController.rescan, scrobble: ssController.scrobble, search: ssController.search3, setRating: ssController.setRating, @@ -197,10 +206,12 @@ const endpoints: ApiController = { getMusicFolderList: ssController.getMusicFolderList, getPlaylistDetail: undefined, getPlaylistList: undefined, + getScanStatus: ssController.getScanStatus, getSongDetail: undefined, getSongList: undefined, getTopSongs: ssController.getTopSongList, getUserList: undefined, + rescan: ssController.rescan, scrobble: ssController.scrobble, search: ssController.search3, setRating: undefined, @@ -469,6 +480,21 @@ const getLyrics = async (args: LyricsArgs) => { )?.(args); }; +const rescan = async (args: RescanArgs) => { + return ( + apiController('rescan', args.apiClientProps.server?.type) as ControllerEndpoint['rescan'] + )?.(args); +}; + +const getScanStatus = async (args: RescanArgs) => { + return ( + apiController( + 'getScanStatus', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getScanStatus'] + )?.(args); +}; + export const controller = { addToPlaylist, authenticate, @@ -488,11 +514,13 @@ export const controller = { getPlaylistList, getPlaylistSongList, getRandomSongList, + getScanStatus, getSongDetail, getSongList, getTopSongList, getUserList, removeFromPlaylist, + rescan, scrobble, search, updatePlaylist, diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 12237410f..559e24471 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -192,6 +192,15 @@ export const contract = c.router({ 400: jfType._response.error, }, }, + refresh: { + body: null, + method: 'POST', + path: 'library/refresh', + responses: { + 204: z.null(), + 400: jfType._response.error, + }, + }, removeFavorite: { body: jfType._parameters.favorite, method: 'DELETE', diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 2ddfb67f8..9f9266a38 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -47,6 +47,8 @@ import { LyricsArgs, LyricsResponse, genreListSortMap, + RescanArgs, + ScanStatus, } from '/@/renderer/api/types'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfNormalize } from './jellyfin-normalize'; @@ -940,6 +942,24 @@ const getLyrics = async (args: LyricsArgs): Promise => { return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]); }; +const rescan = async (args: RescanArgs): Promise => { + const { apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).refresh({ + body: null, + }); + + if (res.status !== 204) { + throw new Error('Failed to get lyrics'); + } + + return { scanning: true }; +}; + export const jfController = { addToPlaylist, authenticate, @@ -962,6 +982,7 @@ export const jfController = { getSongList, getTopSongList, removeFromPlaylist, + rescan, scrobble, search, updatePlaylist, diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index edd7ee3b2..d99738340 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -49,6 +49,13 @@ export const contract = c.router({ 200: ssType._response.randomSongList, }, }, + getScanStatus: { + method: 'GET', + path: 'getScanStatus.view', + responses: { + 200: ssType._response.scanStatus, + }, + }, getTopSongsList: { method: 'GET', path: 'getTopSongs.view', @@ -89,6 +96,14 @@ export const contract = c.router({ 200: ssType._response.setRating, }, }, + startScan: { + method: 'GET', + path: 'startScan.view', + query: ssType._parameters.scan, + responses: { + 200: ssType._response.scanStatus, + }, + }, }); const axiosClient = axios.create({}); diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 32c0de170..f4833f6c6 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -21,6 +21,9 @@ import { SearchResponse, RandomSongListResponse, RandomSongListArgs, + RescanArgs, + ScanStatus, + ScanStatusArgs, } from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; @@ -368,14 +371,66 @@ const getRandomSongList = async (args: RandomSongListArgs): Promise => { + const { full, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await ssApiClient(apiClientProps).startScan({ + query: + full !== undefined + ? { + fullScan: full, + } + : undefined, + }); + + if (res.status !== 200) { + throw new Error('Could not start scan'); + } + + const { scanning, count, folderCount } = res.body.scanStatus; + + return { + folders: folderCount, + scanning, + tracks: count, + }; +}; + +const getScanStatus = async (args: ScanStatusArgs): Promise => { + const { apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await ssApiClient(apiClientProps).getScanStatus(); + if (res.status !== 200) { + throw new Error('Could not start scan'); + } + + const { scanning, count, folderCount } = res.body.scanStatus; + + return { + folders: folderCount, + scanning, + tracks: count, + }; +}; + export const ssController = { authenticate, createFavorite, getArtistInfo, getMusicFolderList, getRandomSongList, + getScanStatus, getTopSongList, removeFavorite, + rescan, scrobble, search3, setRating, diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index 3360081b6..62c6fd40e 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -206,6 +206,19 @@ const randomSongList = z.object({ }), }); +const scanParameters = z.object({ + fullScan: z.boolean().optional(), +}); + +const scanStatus = z.object({ + scanStatus: z.object({ + count: z.number().optional(), + folderCount: z.number().optional(), + lastScan: z.string().optional(), + scanning: z.boolean(), + }), +}); + export const ssType = { _parameters: { albumList: albumListParameters, @@ -214,6 +227,7 @@ export const ssType = { createFavorite: createFavoriteParameters, randomSongList: randomSongListParameters, removeFavorite: removeFavoriteParameters, + scan: scanParameters, scrobble: scrobbleParameters, search3: search3Parameters, setRating: setRatingParameters, @@ -231,6 +245,7 @@ export const ssType = { musicFolderList, randomSongList, removeFavorite, + scanStatus, scrobble, search3, setRating, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 52bf1bc5b..0072c1cd9 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1130,3 +1130,15 @@ export enum LyricSource { } export type LyricsOverride = Omit & { id: string }; + +export type RescanArgs = { + full?: boolean; +} & BaseEndpointArgs; + +export type ScanStatus = { + folders?: number; + scanning: boolean; + tracks?: number; +}; + +export type ScanStatusArgs = BaseEndpointArgs; diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx index 45371547e..54281cf87 100644 --- a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx +++ b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx @@ -37,6 +37,7 @@ import { AppRoute } from '/@/renderer/router/routes'; import { SidebarItemType, useGeneralSettings, useWindowSettings } from '/@/renderer/store'; import { Platform } from '/@/renderer/types'; import { CollapsedSidebarButton } from '/@/renderer/features/sidebar/components/collapsed-sidebar-button'; +import { RescanButton } from '/@/renderer/features/sidebar/components/rescan'; const SidebarContainer = styled(motion.div)<{ $windowBarStyle: Platform }>` display: flex; @@ -141,6 +142,7 @@ export const CollapsedSidebar = () => { )} + void; +}>({ + scanStatus: { scanning: false }, +}); + +export const RescanProvider = ({ children }: { children: ReactNode }) => { + const [scanStatus, setScanStatus] = useState({ scanning: false }); + + const providerValue = useMemo(() => { + return { scanStatus, setScanStatus }; + }, [scanStatus]); + + return {children}; +}; + +const RescanMenu = ({ + timerRef, +}: { + timerRef: MutableRefObject | undefined>; +}) => { + const server = useCurrentServer(); + const { + scanStatus: { scanning, folders, tracks }, + setScanStatus, + } = useContext(RescanContext); + + const isNavidrome = server?.type === ServerType.NAVIDROME; + + useEffect(() => { + if ( + scanning && + timerRef.current === undefined && + server && + server.type !== ServerType.JELLYFIN + ) { + timerRef.current = setInterval(async () => { + const status = await api.controller.getScanStatus({ apiClientProps: { server } }); + if (status) { + setScanStatus!(status); + + if (scanning && !status.scanning) { + toast.success({ + message: 'Scan completed', + }); + } + } + }, 5000); + } else if (!scanning && timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = undefined; + } + }, [scanning, server, setScanStatus, timerRef]); + + const handleRefresh = useCallback( + async (full?: boolean) => { + try { + if (!server) return; + + const results = await api.controller.rescan({ + apiClientProps: { server }, + full, + }); + if (server.type === ServerType.JELLYFIN) { + toast.success({ + message: 'Scan started. Note that Jellyfin does not report progress', + title: 'Started sync', + }); + } else if (results) { + toast.success({ + message: 'Scan started.', + title: 'Started sync', + }); + setScanStatus!({ ...results, scanning: true }); + } + } catch (error) { + console.error(error); + } + }, + [server, setScanStatus], + ); + + return ( + <> + {scanning && ( + + Currently scanning... + + )} + {!scanning && ( + } + onClick={() => handleRefresh(isNavidrome ? false : undefined)} + > + Start scan + + )} + {isNavidrome && !scanning && ( + } + onClick={() => { + handleRefresh(true); + }} + > + Start full scan + + )} + {(isNavidrome || server?.type === ServerType.SUBSONIC) && ( + <> + Folders: {folders ?? '-'} + Tracks: {tracks ?? '-'} + + )} + + ); +}; + +export const RescanButton = () => { + const { scanStatus, setScanStatus } = useContext(RescanContext); + const timerRef = useRef>(); + const server = useCurrentServer(); + + useEffect(() => { + if (setScanStatus) setScanStatus({ scanning: false }); + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = undefined; + } + }; + }, [server, setScanStatus]); + + return ( + + + + {scanStatus.scanning ? ( + + ) : ( + + )} + Rescan {scanStatus.scanning ? 'in progress' : ''} + + + + + + + ); +}; + +export const RescanSiderbar = () => { + const { scanStatus, setScanStatus } = useContext(RescanContext); + const server = useCurrentServer(); + const timerRef = useRef>(); + + useEffect(() => { + if (setScanStatus) setScanStatus({ scanning: false }); + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = undefined; + } + }; + }, [server, setScanStatus]); + + return ( + // Note, tabIndex -1 is intentional here to make the Button the tabable component + + + + + + {scanStatus.scanning ? ( + + ) : ( + + )} + Rescan {scanStatus.scanning ? 'in progress' : ''} + + + + + + + + + ); +}; diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index c3784db6d..2e20d875a 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -54,6 +54,7 @@ import { } from '/@/renderer/store'; import { fadeIn } from '/@/renderer/styles'; import { Platform } from '/@/renderer/types'; +import { RescanSiderbar } from '/@/renderer/features/sidebar/components/rescan'; const SidebarContainer = styled.div<{ $windowBarStyle: Platform }>` height: 100%; @@ -214,6 +215,7 @@ export const Sidebar = () => { sx={{ maxHeight: showImage ? `calc(100% - ${sidebar.leftWidth})` : '100%' }} > + {sidebarItemsWithRoute.map((item) => ( startResizing('left'); }} /> - {collapsed ? : } + {collapsed ? : } ); }; From f6695052d0118e8725359c72915a0f1d878d6f28 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Wed, 27 Sep 2023 21:23:21 -0700 Subject: [PATCH 2/3] copypasta fix --- src/renderer/api/jellyfin/jellyfin-controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 9f9266a38..e7cbe6b07 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -954,7 +954,7 @@ const rescan = async (args: RescanArgs): Promise => { }); if (res.status !== 204) { - throw new Error('Failed to get lyrics'); + throw new Error('Failed to start scan'); } return { scanning: true }; From b4e3c1a289537f429201d69ebf8c79b117d20fac Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 9 Oct 2023 20:19:29 -0700 Subject: [PATCH 3/3] make rescan a sortable sidebar --- .../sidebar/components/collapsed-sidebar.tsx | 31 +++++++++------ .../features/sidebar/components/sidebar.tsx | 39 +++++++++++-------- src/renderer/router/routes.ts | 1 + src/renderer/store/settings.store.ts | 1 + 4 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx index 54281cf87..ad6e9b09e 100644 --- a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx +++ b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx @@ -91,6 +91,10 @@ const sidebarItemMap = { activeIcon: RiPlayFill, icon: RiPlayLine, }, + [AppRoute.RESCAN]: { + activeIcon: undefined, + icon: undefined, + }, }; export const CollapsedSidebar = () => { @@ -142,7 +146,6 @@ export const CollapsedSidebar = () => { )} - { - {sidebarItemsWithRoute.map((item) => ( - } - component={NavLink} - icon={} - label={item.label} - route={item.route} - to={item.route} - /> - ))} + {sidebarItemsWithRoute.map((item) => + item.route === AppRoute.RESCAN ? ( + + ) : ( + } + component={NavLink} + icon={} + label={item.label} + route={item.route} + to={item.route} + /> + ), + )} ); diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index 2e20d875a..1710fa887 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -134,6 +134,10 @@ const sidebarItemMap = { activeIcon: RiPlayFill, icon: RiPlayLine, }, + [AppRoute.RESCAN]: { + activeIcon: undefined, + icon: undefined, + }, }; export const Sidebar = () => { @@ -215,22 +219,25 @@ export const Sidebar = () => { sx={{ maxHeight: showImage ? `calc(100% - ${sidebar.leftWidth})` : '100%' }} > - - {sidebarItemsWithRoute.map((item) => ( - - - {location.pathname === item.route ? ( - - ) : ( - - )} - {item.label} - - - ))} + {sidebarItemsWithRoute.map((item) => + item.route === AppRoute.RESCAN ? ( + + ) : ( + + + {location.pathname === item.route ? ( + + ) : ( + + )} + {item.label} + + + ), + )}