diff --git a/src/renderer/api/api-controller-logger.ts b/src/renderer/api/api-controller-logger.ts index 4b4759517..54d23ec5f 100644 --- a/src/renderer/api/api-controller-logger.ts +++ b/src/renderer/api/api-controller-logger.ts @@ -73,7 +73,7 @@ function createLoggedFunction( return undefined; } - return async (request: TRequest, server: ServerListItem, options?: any) => { + return async (request: TRequest, requestOptions?: any) => { const startTime = Date.now(); const requestId = Math.random().toString(36).substring(2, 15); @@ -96,13 +96,11 @@ function createLoggedFunction( logger.info(`[API] ${functionName} called`, { request: truncatedRequest, requestId, - serverId: server.id, - serverType: server.type, }); } try { - const result = await originalFn(request, server, options); + const result = await originalFn(request, requestOptions); const duration = Date.now() - startTime; if (result[0]) { diff --git a/src/renderer/api/api-controller.ts b/src/renderer/api/api-controller.ts index 010d73667..6cb34de51 100644 --- a/src/renderer/api/api-controller.ts +++ b/src/renderer/api/api-controller.ts @@ -1,47 +1,75 @@ import { createLoggedApiController } from '/@/renderer/api/api-controller-logger'; -import { getServerById } from '/@/renderer/store'; +import { useAuthStore } from '/@/renderer/store'; import { - apiClient as subsonicApiClient, - controller as subsonicBaseAdapter, + createApiClient as subsonicApiClient, + authenticate as subsonicAuthenticate, + controller as subsonicController, middleware as subsonicMiddleware, } from '/@/shared/api/subsonic/subsonic-controller'; import { ApiController } from '/@/shared/types/adapter/api-controller-types'; import { ServerType } from '/@/shared/types/domain/server-domain-types'; -export const serverApi = { +export const serverApiMap = { [ServerType.JELLYFIN]: { apiClient: null, + authenticate: null, controller: {}, middleware: null, }, [ServerType.NAVIDROME]: { apiClient: null, + authenticate: null, controller: {}, middleware: null, }, [ServerType.SUBSONIC]: { apiClient: subsonicApiClient, - controller: createLoggedApiController(subsonicBaseAdapter), + authenticate: subsonicAuthenticate, + controller: subsonicController, middleware: subsonicMiddleware, }, }; -export const api = (serverId: string): ApiController => { - const server = getServerById(serverId); +const getApiByServer = (serverId: string): ApiController => { + const servers = useAuthStore.getState().serverList; + const server = servers[serverId]; if (!server) { throw new Error('No server or api client selected'); } - const { apiClient, controller, middleware } = serverApi[server.type]; - - if (middleware) { - apiClient.use(middleware(server)); - } + const { apiClient, controller, middleware } = serverApiMap[server.type]; if (!apiClient) { throw new Error('No api client found'); } - return controller as ApiController; + const client = apiClient(server, middleware); + + return createLoggedApiController(controller(client, server)); +}; + +const getAppApi = () => { + const servers = useAuthStore.getState().serverList; + + return Object.entries(servers).reduce( + (acc, [id]) => { + acc[id] = getApiByServer(id); + return acc; + }, + {} as Record, + ); +}; + +export const api = { + authenticate: (serverType: ServerType) => { + const { authenticate } = serverApiMap[serverType]; + + if (!serverType || !authenticate) { + throw new Error(); + } + + return authenticate; + }, + controller: getAppApi(), }; diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 5743fec9e..71c482a32 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -322,7 +322,7 @@ export const JellyfinController: ControllerEndpoint = { SearchTerm: query.searchTerm, SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName', SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, + StartIndex: query.offset, ...query._custom?.jellyfin, Years: yearsFilter, }, @@ -334,14 +334,14 @@ export const JellyfinController: ControllerEndpoint = { return { items: res.body.Items.map((item) => jfNormalize.album(item, apiClientProps.server)), - startIndex: query.startIndex, + offset: query.offset, totalRecordCount: res.body.TotalRecordCount, }; }, getAlbumListCount: async ({ apiClientProps, query }) => JellyfinController.getAlbumList({ apiClientProps, - query: { ...query, limit: 1, startIndex: 0 }, + query: { ...query, limit: 1, offset: 0 }, }).then((result) => result!.totalRecordCount!), getArtistList: async (args) => { const { apiClientProps, query } = args; @@ -356,7 +356,7 @@ export const JellyfinController: ControllerEndpoint = { SearchTerm: query.searchTerm, SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name', SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, + StartIndex: query.offset, UserId: apiClientProps.server?.userId || undefined, }, }); @@ -369,14 +369,14 @@ export const JellyfinController: ControllerEndpoint = { items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server), ), - startIndex: query.startIndex, + offset: query.offset, totalRecordCount: res.body.TotalRecordCount, }; }, getArtistListCount: async ({ apiClientProps, query }) => JellyfinController.getArtistList({ apiClientProps, - query: { ...query, limit: 1, startIndex: 0 }, + query: { ...query, limit: 1, offset: 0 }, }).then((result) => result!.totalRecordCount!), getDownloadUrl: (args) => { const { apiClientProps, query } = args; @@ -398,7 +398,7 @@ export const JellyfinController: ControllerEndpoint = { SearchTerm: query?.searchTerm, SortBy: genreListSortMap.jellyfin[query.sortBy] || 'SortName', SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, + StartIndex: query.offset, UserId: apiClientProps.server?.userId, }, }); @@ -409,7 +409,7 @@ export const JellyfinController: ControllerEndpoint = { return { items: res.body.Items.map((item) => jfNormalize.genre(item, apiClientProps.server)), - startIndex: query.startIndex || 0, + offset: query.offset || 0, totalRecordCount: res.body?.TotalRecordCount || 0, }; }, @@ -458,7 +458,7 @@ export const JellyfinController: ControllerEndpoint = { return { items: musicFolders.map(jfNormalize.musicFolder), - startIndex: 0, + offset: 0, totalRecordCount: musicFolders?.length || 0, }; }, @@ -505,7 +505,7 @@ export const JellyfinController: ControllerEndpoint = { SearchTerm: query.searchTerm, SortBy: playlistListSortMap.jellyfin[query.sortBy], SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, + StartIndex: query.offset, }, }); @@ -515,14 +515,14 @@ export const JellyfinController: ControllerEndpoint = { return { items: res.body.Items.map((item) => jfNormalize.playlist(item, apiClientProps.server)), - startIndex: 0, + offset: 0, totalRecordCount: res.body.TotalRecordCount, }; }, getPlaylistListCount: async ({ apiClientProps, query }) => JellyfinController.getPlaylistList({ apiClientProps, - query: { ...query, limit: 1, startIndex: 0 }, + query: { ...query, limit: 1, offset: 0 }, }).then((result) => result!.totalRecordCount!), getPlaylistSongList: async (args) => { const { apiClientProps, query } = args; @@ -552,7 +552,7 @@ export const JellyfinController: ControllerEndpoint = { return { items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), - startIndex: query.startIndex, + offset: query.startIndex, totalRecordCount: res.body.TotalRecordCount, }; }, @@ -602,7 +602,7 @@ export const JellyfinController: ControllerEndpoint = { return { items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), - startIndex: 0, + offset: 0, totalRecordCount: res.body.Items.length || 0, }; }, @@ -742,7 +742,7 @@ export const JellyfinController: ControllerEndpoint = { SearchTerm: query.searchTerm, SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName', SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, + StartIndex: query.offset, ...query._custom?.jellyfin, Years: yearsFilter, }, @@ -777,7 +777,7 @@ export const JellyfinController: ControllerEndpoint = { SearchTerm: query.searchTerm, SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName', SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, + StartIndex: query.offset, ...query._custom?.jellyfin, Years: yearsFilter, }, @@ -806,14 +806,14 @@ export const JellyfinController: ControllerEndpoint = { items: items.map((item) => jfNormalize.song(item, apiClientProps.server, '', query.imageSize), ), - startIndex: query.startIndex, + offset: query.offset, totalRecordCount, }; }, getSongListCount: async ({ apiClientProps, query }) => JellyfinController.getSongList({ apiClientProps, - query: { ...query, limit: 1, startIndex: 0 }, + query: { ...query, limit: 1, offset: 0 }, }).then((result) => result!.totalRecordCount!), getTags: async (args) => { const { apiClientProps, query } = args; @@ -869,7 +869,7 @@ export const JellyfinController: ControllerEndpoint = { return { items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), - startIndex: 0, + offset: 0, totalRecordCount: res.body.TotalRecordCount, }; }, diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 67814414b..02013a3d4 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -273,10 +273,10 @@ export const NavidromeController: ControllerEndpoint = { const res = await ndApiClient(apiClientProps).getAlbumList({ query: { - _end: query.startIndex + (query.limit || 0), + _end: query.offset + (query.limit || 0), _order: sortOrderMap.navidrome[query.sortOrder], _sort: albumListSortMap.navidrome[query.sortBy], - _start: query.startIndex, + _start: query.offset, artist_id: query.artistIds?.[0], compilation: query.compilation, genre_id: query.genres?.[0], @@ -293,24 +293,24 @@ export const NavidromeController: ControllerEndpoint = { return { items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)), - startIndex: query?.startIndex || 0, + offset: query?.offset || 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }, getAlbumListCount: async ({ apiClientProps, query }) => NavidromeController.getAlbumList({ apiClientProps, - query: { ...query, limit: 1, startIndex: 0 }, + query: { ...query, limit: 1, offset: 0 }, }).then((result) => result!.totalRecordCount!), getArtistList: async (args) => { const { apiClientProps, query } = args; const res = await ndApiClient(apiClientProps).getAlbumArtistList({ query: { - _end: query.startIndex + (query.limit || 0), + _end: query.offset + (query.limit || 0), _order: sortOrderMap.navidrome[query.sortOrder], _sort: albumArtistListSortMap.navidrome[query.sortBy], - _start: query.startIndex, + _start: query.offset, name: query.searchTerm, ...query._custom?.navidrome, role: query.role || undefined, @@ -335,14 +335,14 @@ export const NavidromeController: ControllerEndpoint = { apiClientProps.server, ), ), - startIndex: query.startIndex, + offset: query.offset, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }, getArtistListCount: async ({ apiClientProps, query }) => NavidromeController.getArtistList({ apiClientProps, - query: { ...query, limit: 1, startIndex: 0 }, + query: { ...query, limit: 1, offset: 0 }, }).then((result) => result!.totalRecordCount!), getDownloadUrl: SubsonicController.getDownloadUrl, getGenreList: async (args) => { @@ -350,10 +350,10 @@ export const NavidromeController: ControllerEndpoint = { const res = await ndApiClient(apiClientProps).getGenreList({ query: { - _end: query.startIndex + (query.limit || 0), + _end: query.offset + (query.limit || 0), _order: sortOrderMap.navidrome[query.sortOrder], _sort: genreListSortMap.navidrome[query.sortBy], - _start: query.startIndex, + _start: query.offset, name: query.searchTerm, }, }); @@ -364,7 +364,7 @@ export const NavidromeController: ControllerEndpoint = { return { items: res.body.data.map((genre) => ndNormalize.genre(genre)), - startIndex: query.startIndex || 0, + offset: query.offset || 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }, @@ -400,10 +400,10 @@ export const NavidromeController: ControllerEndpoint = { const res = await ndApiClient(apiClientProps).getPlaylistList({ query: { - _end: query.startIndex + (query.limit || 0), + _end: query.offset + (query.limit || 0), _order: sortOrderMap.navidrome[query.sortOrder], _sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined, - _start: query.startIndex, + _start: query.offset, q: query.searchTerm, ...customQuery, }, @@ -415,14 +415,14 @@ export const NavidromeController: ControllerEndpoint = { return { items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)), - startIndex: query?.startIndex || 0, + offset: query?.offset || 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }, getPlaylistListCount: async ({ apiClientProps, query }) => NavidromeController.getPlaylistList({ apiClientProps, - query: { ...query, limit: 1, startIndex: 0 }, + query: { ...query, limit: 1, offset: 0 }, }).then((result) => result!.totalRecordCount!), getPlaylistSongList: async ( args: PlaylistSongListRequest, @@ -450,7 +450,7 @@ export const NavidromeController: ControllerEndpoint = { return { items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server)), - startIndex: query?.startIndex || 0, + offset: query?.startIndex || 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }, @@ -576,10 +576,10 @@ export const NavidromeController: ControllerEndpoint = { const res = await ndApiClient(apiClientProps).getSongList({ query: { - _end: query.startIndex + (query.limit || -1), + _end: query.offset + (query.limit || -1), _order: sortOrderMap.navidrome[query.sortOrder], _sort: songListSortMap.navidrome[query.sortBy], - _start: query.startIndex, + _start: query.offset, album_artist_id: query.albumArtistIds, album_id: query.albumIds, artist_id: query.artistIds, @@ -599,14 +599,14 @@ export const NavidromeController: ControllerEndpoint = { items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, query.imageSize), ), - startIndex: query?.startIndex || 0, + offset: query?.offset || 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }, getSongListCount: async ({ apiClientProps, query }) => NavidromeController.getSongList({ apiClientProps, - query: { ...query, limit: 1, startIndex: 0 }, + query: { ...query, limit: 1, offset: 0 }, }).then((result) => result!.totalRecordCount!), getStructuredLyrics: SubsonicController.getStructuredLyrics, getTags: async (args) => { @@ -655,10 +655,10 @@ export const NavidromeController: ControllerEndpoint = { const res = await ndApiClient(apiClientProps).getUserList({ query: { - _end: query.startIndex + (query.limit || 0), + _end: query.offset + (query.limit || 0), _order: sortOrderMap.navidrome[query.sortOrder], _sort: userListSortMap.navidrome[query.sortBy], - _start: query.startIndex, + _start: query.offset, ...query._custom?.navidrome, }, }); @@ -669,7 +669,7 @@ export const NavidromeController: ControllerEndpoint = { return { items: res.body.data.map((user) => ndNormalize.user(user)), - startIndex: query?.startIndex || 0, + offset: query?.offset || 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }, diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 0e383c1a7..36e6b53d0 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -269,7 +269,7 @@ export const SubsonicController: ControllerEndpoint = { const res = await ssApiClient(apiClientProps).search3({ query: { albumCount: query.limit, - albumOffset: query.startIndex, + albumOffset: query.offset, artistCount: 0, artistOffset: 0, query: query.searchTerm || '', @@ -289,7 +289,7 @@ export const SubsonicController: ControllerEndpoint = { return { items: results, - startIndex: query.startIndex, + offset: query.offset, totalRecordCount: null, }; } @@ -323,7 +323,7 @@ export const SubsonicController: ControllerEndpoint = { return { items: sortAlbumList(items, query.sortBy, query.sortOrder), - startIndex: 0, + offset: 0, totalRecordCount: albums.length, }; } @@ -346,7 +346,7 @@ export const SubsonicController: ControllerEndpoint = { return { items: sortAlbumList(results, query.sortBy, query.sortOrder), - startIndex: 0, + offset: 0, totalRecordCount: res.body.starred?.album?.length || 0, }; } @@ -390,7 +390,7 @@ export const SubsonicController: ControllerEndpoint = { fromYear, genre: query.genres?.length ? query.genres[0] : undefined, musicFolderId: query.musicFolderId, - offset: query.startIndex, + offset: query.offset, size: query.limit, toYear, type, @@ -406,7 +406,7 @@ export const SubsonicController: ControllerEndpoint = { res.body.albumList2.album?.map((album) => normalize.album(album, apiClientProps.server, 300), ) || [], - startIndex: query.startIndex, + offset: query.offset, totalRecordCount: null, }; }, @@ -591,7 +591,7 @@ export const SubsonicController: ControllerEndpoint = { return { items: results, - startIndex: query.startIndex, + offset: query.offset, totalRecordCount: results?.length || 0, }; }, @@ -639,7 +639,7 @@ export const SubsonicController: ControllerEndpoint = { return { items: genres, - startIndex: 0, + offset: 0, totalRecordCount: genres.length, }; }, @@ -657,7 +657,7 @@ export const SubsonicController: ControllerEndpoint = { id: folder.id.toString(), name: folder.name, })), - startIndex: 0, + offset: 0, totalRecordCount: res.body.musicFolders.musicFolder.length, }; }, @@ -720,7 +720,7 @@ export const SubsonicController: ControllerEndpoint = { return { items: results.map((playlist) => normalize.playlist(playlist, apiClientProps.server)), - startIndex: 0, + offset: 0, totalRecordCount: results.length, }; }, @@ -764,7 +764,7 @@ export const SubsonicController: ControllerEndpoint = { return { items: results, - startIndex: 0, + offset: 0, totalRecordCount: results?.length || 0, }; }, @@ -789,7 +789,7 @@ export const SubsonicController: ControllerEndpoint = { return { items: results.map((song) => normalize.song(song, apiClientProps.server)), - startIndex: 0, + offset: 0, totalRecordCount: res.body.randomSongs?.song?.length || 0, }; }, @@ -907,7 +907,7 @@ export const SubsonicController: ControllerEndpoint = { artistOffset: 0, query: query.searchTerm || '', songCount: query.limit, - songOffset: query.startIndex, + songOffset: query.offset, }, }); @@ -920,7 +920,7 @@ export const SubsonicController: ControllerEndpoint = { res.body.searchResult3?.song?.map((song) => normalize.song(song, apiClientProps.server), ) || [], - startIndex: query.startIndex, + offset: query.offset, totalRecordCount: null, }; } @@ -931,7 +931,7 @@ export const SubsonicController: ControllerEndpoint = { count: query.limit, genre: query.genreIds[0], musicFolderId: query.musicFolderId, - offset: query.startIndex, + offset: query.offset, }, }); @@ -943,7 +943,7 @@ export const SubsonicController: ControllerEndpoint = { return { items: results.map((song) => normalize.song(song, apiClientProps.server)) || [], - startIndex: 0, + offset: 0, totalRecordCount: null, }; } @@ -966,7 +966,7 @@ export const SubsonicController: ControllerEndpoint = { return { items: sortSongList(results, query.sortBy, query.sortOrder), - startIndex: 0, + offset: 0, totalRecordCount: (res.body.starred?.song || []).length || 0, }; } @@ -1036,7 +1036,7 @@ export const SubsonicController: ControllerEndpoint = { return { items: results.map((song) => normalize.song(song, apiClientProps.server)), - startIndex: 0, + offset: 0, totalRecordCount: results.length, }; } @@ -1049,7 +1049,7 @@ export const SubsonicController: ControllerEndpoint = { artistOffset: 0, query: query.searchTerm || '', songCount: query.limit, - songOffset: query.startIndex, + songOffset: query.offset, }, }); @@ -1062,7 +1062,7 @@ export const SubsonicController: ControllerEndpoint = { res.body.searchResult3?.song?.map((song) => normalize.song(song, apiClientProps.server), ) || [], - startIndex: 0, + offset: 0, totalRecordCount: null, }; }, @@ -1299,7 +1299,7 @@ export const SubsonicController: ControllerEndpoint = { res.body.topSongs?.song?.map((song) => normalize.song(song, apiClientProps.server), ) || [], - startIndex: 0, + offset: 0, totalRecordCount: res.body.topSongs?.song?.length || 0, }; }, diff --git a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts index f16e98afe..a95426ad0 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -23,7 +23,10 @@ import { SetContextMenuItems, useHandleTableContextMenu } from '/@/renderer/feat import { AppRoute } from '/@/renderer/router/routes'; import { PersistedTableColumn, useListStoreActions } from '/@/renderer/store'; import { ListKey, useListStoreByKey } from '/@/renderer/store/list.store'; -import { BasePaginatedResponse, BaseQuery } from '/@/shared/types/adapter/api-controller-types'; +import { + BasePaginatedResponse, + BasePaginatedQuery, +} from '/@/shared/types/adapter/api-controller-types'; import { ServerListItem } from '/@/shared/types/domain/server-domain-types'; import { LibraryItem } from '/@/shared/types/domain/shared-domain-types'; import { ListDisplayType, TablePagination } from '/@/shared/types/types'; @@ -49,7 +52,7 @@ interface UseAgGridProps { const BLOCK_SIZE = 500; -export const useVirtualTable = >({ +export const useVirtualTable = >({ columnType, contextMenu, customFilters, diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index 012bb4a0c..4caec444e 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -150,8 +150,8 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP const artistQuery = useAlbumList({ options: { - cacheTime: 1000 * 60, enabled: detailQuery?.data?.albumArtists[0]?.id !== undefined, + gcTime: 1000 * 60, keepPreviousData: true, staleTime: 1000 * 60, }, @@ -165,9 +165,9 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP ? [detailQuery?.data?.albumArtists[0].id] : undefined, limit: 15, + offset: 0, sortBy: AlbumListSort.YEAR, sortOrder: ListSortOrder.DESC, - startIndex: 0, }, serverId: server?.id, }); @@ -175,15 +175,15 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP const relatedAlbumGenresRequest: AlbumListQuery = { genres: detailQuery.data?.genres.length ? [detailQuery.data.genres[0].id] : undefined, limit: 15, + offset: 0, sortBy: AlbumListSort.RANDOM, sortOrder: ListSortOrder.ASC, - startIndex: 0, }; const relatedAlbumGenresQuery = useAlbumList({ options: { - cacheTime: 1000 * 60, enabled: !!detailQuery?.data?.genres?.[0], + gcTime: 1000 * 60, queryKey: queryKeys.albums.related( server?.id || '', albumId, diff --git a/src/renderer/features/albums/components/album-list-grid-view.tsx b/src/renderer/features/albums/components/album-list-grid-view.tsx index 0db4a098e..5a0862005 100644 --- a/src/renderer/features/albums/components/album-list-grid-view.tsx +++ b/src/renderer/features/albums/components/album-list-grid-view.tsx @@ -137,15 +137,11 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => { const itemData: Album[] = []; for (const [, data] of queriesFromCache) { - const { items, startIndex } = data || {}; + const { items, offset } = data || {}; - if (items && items.length !== 1 && startIndex !== undefined) { + if (items && items.length !== 1 && offset !== undefined) { let itemIndex = 0; - for ( - let rowIndex = startIndex; - rowIndex < startIndex + items.length; - rowIndex += 1 - ) { + for (let rowIndex = offset; rowIndex < offset + items.length; rowIndex += 1) { itemData[rowIndex] = items[itemIndex]; itemIndex += 1; } @@ -165,7 +161,7 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => { limit: take, ...filter, ...customFilters, - startIndex: skip, + offset: skip, }; const queryKey = queryKeys.albums.list(server?.id || '', query, id); diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index c82e8b73d..67c54c34f 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -41,14 +41,14 @@ export const JellyfinAlbumFilters = ({ // TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library const genreListQuery = useGenreList({ options: { - cacheTime: 1000 * 60 * 2, + gcTime: 1000 * 60 * 2, staleTime: 1000 * 60 * 1, }, query: { musicFolderId: filter?.musicFolderId, sortBy: GenreListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId, }); @@ -63,7 +63,7 @@ export const JellyfinAlbumFilters = ({ const tagsQuery = useTagList({ options: { - cacheTime: 1000 * 60 * 2, + gcTime: 1000 * 60 * 2, staleTime: 1000 * 60 * 1, }, query: { @@ -170,7 +170,7 @@ export const JellyfinAlbumFilters = ({ const albumArtistListQuery = useAlbumArtistList({ options: { - cacheTime: 1000 * 60 * 2, + gcTime: 1000 * 60 * 2, staleTime: 1000 * 60 * 1, }, query: { diff --git a/src/renderer/features/albums/components/navidrome-album-filters.tsx b/src/renderer/features/albums/components/navidrome-album-filters.tsx index 77c5e6434..026f11d49 100644 --- a/src/renderer/features/albums/components/navidrome-album-filters.tsx +++ b/src/renderer/features/albums/components/navidrome-album-filters.tsx @@ -42,13 +42,13 @@ export const NavidromeAlbumFilters = ({ const genreListQuery = useGenreList({ options: { - cacheTime: 1000 * 60 * 2, + gcTime: 1000 * 60 * 2, staleTime: 1000 * 60 * 1, }, query: { sortBy: GenreListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId, }); @@ -76,7 +76,7 @@ export const NavidromeAlbumFilters = ({ const tagsQuery = useTagList({ options: { - cacheTime: 1000 * 60 * 2, + gcTime: 1000 * 60 * 2, staleTime: 1000 * 60 * 1, }, query: { @@ -185,7 +185,7 @@ export const NavidromeAlbumFilters = ({ const albumArtistListQuery = useAlbumArtistList({ options: { - cacheTime: 1000 * 60 * 2, + gcTime: 1000 * 60 * 2, staleTime: 1000 * 60 * 1, }, query: { diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx index 6fcfb2251..c2fb71e52 100644 --- a/src/renderer/features/albums/components/subsonic-album-filters.tsx +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -39,7 +39,7 @@ export const SubsonicAlbumFilters = ({ const albumArtistListQuery = useAlbumArtistList({ options: { - cacheTime: 1000 * 60 * 2, + gcTime: 1000 * 60 * 2, staleTime: 1000 * 60 * 1, }, query: { @@ -72,13 +72,13 @@ export const SubsonicAlbumFilters = ({ const genreListQuery = useGenreList({ options: { - cacheTime: 1000 * 60 * 2, + gcTime: 1000 * 60 * 2, staleTime: 1000 * 60 * 1, }, query: { sortBy: GenreListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId, }); diff --git a/src/renderer/features/albums/queries/album-detail-query.ts b/src/renderer/features/albums/queries/album-detail-query.ts index 2e64c5bc7..e4ff40c71 100644 --- a/src/renderer/features/albums/queries/album-detail-query.ts +++ b/src/renderer/features/albums/queries/album-detail-query.ts @@ -4,12 +4,12 @@ import { useQuery } from '@tanstack/react-query'; import { controller } from '/@/renderer/api/controller'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { AlbumDetailQuery } from '/@/shared/types/domain/album-domain-types'; export const useAlbumDetail = (args: QueryHookArgs) => { const { options, query, serverId } = args; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ queryFn: ({ signal }) => { diff --git a/src/renderer/features/albums/queries/album-list-count-query.ts b/src/renderer/features/albums/queries/album-list-count-query.ts index 91a3159fc..05f0a5879 100644 --- a/src/renderer/features/albums/queries/album-list-count-query.ts +++ b/src/renderer/features/albums/queries/album-list-count-query.ts @@ -4,12 +4,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { AlbumListQuery } from '/@/shared/types/domain/album-domain-types'; export const useAlbumListCount = (args: QueryHookArgs) => { const { options, query, serverId } = args; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!serverId, diff --git a/src/renderer/features/albums/queries/album-list-query.ts b/src/renderer/features/albums/queries/album-list-query.ts index 70a0461c1..70c7e1f06 100644 --- a/src/renderer/features/albums/queries/album-list-query.ts +++ b/src/renderer/features/albums/queries/album-list-query.ts @@ -5,12 +5,12 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { controller } from '/@/renderer/api/controller'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { AlbumListQuery, AlbumListResponse } from '/@/shared/types/domain/album-domain-types'; export const useAlbumList = (args: QueryHookArgs) => { const { options, query, serverId } = args; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!serverId, @@ -35,7 +35,7 @@ export const useAlbumList = (args: QueryHookArgs) => { export const useAlbumListInfinite = (args: QueryHookArgs) => { const { options, query, serverId } = args; - const server = getServerById(serverId); + const server = useServerById(serverId); return useInfiniteQuery({ enabled: !!serverId, @@ -57,7 +57,7 @@ export const useAlbumListInfinite = (args: QueryHookArgs) => { query: { ...query, limit: query.limit || 50, - startIndex: pageParam * (query.limit || 50), + offset: pageParam * (query.limit || 50), }, }); }, diff --git a/src/renderer/features/albums/routes/album-list-route.tsx b/src/renderer/features/albums/routes/album-list-route.tsx index c8fea9365..4818bcdb7 100644 --- a/src/renderer/features/albums/routes/album-list-route.tsx +++ b/src/renderer/features/albums/routes/album-list-route.tsx @@ -52,13 +52,13 @@ const AlbumListRoute = () => { const genreList = useGenreList({ options: { - cacheTime: 1000 * 60 * 60, + gcTime: 1000 * 60 * 60, enabled: !!genreId, }, query: { sortBy: GenreListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); @@ -74,7 +74,7 @@ const AlbumListRoute = () => { const itemCountCheck = useAlbumListCount({ options: { - cacheTime: 1000 * 60, + gcTime: 1000 * 60, staleTime: 1000 * 60, }, query: { diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx index ccf144e66..0a1f0580e 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -102,7 +102,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten limit: 15, sortBy: AlbumListSort.RELEASE_DATE, sortOrder: ListSortOrder.DESC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); @@ -117,7 +117,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten limit: 15, sortBy: AlbumListSort.RELEASE_DATE, sortOrder: ListSortOrder.DESC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); diff --git a/src/renderer/features/artists/components/artist-list-grid-view.tsx b/src/renderer/features/artists/components/artist-list-grid-view.tsx index 312289477..b790b07b3 100644 --- a/src/renderer/features/artists/components/artist-list-grid-view.tsx +++ b/src/renderer/features/artists/components/artist-list-grid-view.tsx @@ -55,15 +55,11 @@ export const ArtistListGridView = ({ gridRef, itemCount }: ArtistListGridViewPro const itemData: AlbumArtist[] = []; for (const [, data] of queriesFromCache) { - const { items, startIndex } = data || {}; + const { items, offset } = data || {}; - if (items && items.length !== 1 && startIndex !== undefined) { + if (items && items.length !== 1 && offset !== undefined) { let itemIndex = 0; - for ( - let rowIndex = startIndex; - rowIndex < startIndex + items.length; - rowIndex += 1 - ) { + for (let rowIndex = offset; rowIndex < offset + items.length; rowIndex += 1) { itemData[rowIndex] = items[itemIndex]; itemIndex += 1; } @@ -78,7 +74,7 @@ export const ArtistListGridView = ({ gridRef, itemCount }: ArtistListGridViewPro const query: ArtistListQuery = { ...filter, limit, - startIndex, + offset, }; const queryKey = queryKeys.artists.list(server?.id || '', query); diff --git a/src/renderer/features/artists/components/artist-list-header-filters.tsx b/src/renderer/features/artists/components/artist-list-header-filters.tsx index d1320b82b..a11e210e3 100644 --- a/src/renderer/features/artists/components/artist-list-header-filters.tsx +++ b/src/renderer/features/artists/components/artist-list-header-filters.tsx @@ -140,7 +140,7 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF const cq = useContainerQuery(); const roles = useRoles({ options: { - cacheTime: 1000 * 60 * 60 * 2, + gcTime: 1000 * 60 * 60 * 2, staleTime: 1000 * 60 * 60 * 2, }, query: {}, @@ -188,7 +188,7 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF }, query: { limit, - startIndex, + offset, ...filters, }, }), @@ -224,7 +224,7 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF }, query: { limit, - startIndex, + offset, ...filters, }, }), diff --git a/src/renderer/features/artists/queries/album-artist-detail-query.ts b/src/renderer/features/artists/queries/album-artist-detail-query.ts index 2686773e1..3e79afdd3 100644 --- a/src/renderer/features/artists/queries/album-artist-detail-query.ts +++ b/src/renderer/features/artists/queries/album-artist-detail-query.ts @@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { AlbumArtistDetailQuery } from '/@/shared/types/domain/artist-domain-types'; export const useAlbumArtistDetail = (args: QueryHookArgs) => { const { options, query, serverId } = args || {}; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!server?.id && !!query.id, diff --git a/src/renderer/features/artists/queries/album-artist-list-count-query.ts b/src/renderer/features/artists/queries/album-artist-list-count-query.ts index 3952f03f6..c1c8603c7 100644 --- a/src/renderer/features/artists/queries/album-artist-list-count-query.ts +++ b/src/renderer/features/artists/queries/album-artist-list-count-query.ts @@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { AlbumArtistListQuery } from '/@/shared/types/domain/artist-domain-types'; export const useAlbumArtistListCount = (args: QueryHookArgs) => { const { options, query, serverId } = args; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!serverId, diff --git a/src/renderer/features/artists/queries/album-artist-list-query.ts b/src/renderer/features/artists/queries/album-artist-list-query.ts index 31ce6759b..eb7518037 100644 --- a/src/renderer/features/artists/queries/album-artist-list-query.ts +++ b/src/renderer/features/artists/queries/album-artist-list-query.ts @@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { AlbumArtistListQuery } from '/@/shared/types/domain/artist-domain-types'; export const useAlbumArtistList = (args: QueryHookArgs) => { const { options, query, serverId } = args || {}; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!server?.id, diff --git a/src/renderer/features/artists/queries/artist-info-query.ts b/src/renderer/features/artists/queries/artist-info-query.ts index a302a6150..383d3b54c 100644 --- a/src/renderer/features/artists/queries/artist-info-query.ts +++ b/src/renderer/features/artists/queries/artist-info-query.ts @@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { AlbumArtistDetailQuery } from '/@/shared/types/domain/artist-domain-types'; export const useAlbumArtistInfo = (args: QueryHookArgs) => { const { options, query, serverId } = args || {}; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!server?.id && !!query.id, diff --git a/src/renderer/features/artists/queries/artist-list-count-query.ts b/src/renderer/features/artists/queries/artist-list-count-query.ts index 453c2c1f0..141e0bcb3 100644 --- a/src/renderer/features/artists/queries/artist-list-count-query.ts +++ b/src/renderer/features/artists/queries/artist-list-count-query.ts @@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { ArtistListQuery } from '/@/shared/types/domain/artist-domain-types'; export const useArtistListCount = (args: QueryHookArgs) => { const { options, query, serverId } = args; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!serverId, diff --git a/src/renderer/features/artists/queries/roles-query.ts b/src/renderer/features/artists/queries/roles-query.ts index 0f9c40b4b..c3ab2d671 100644 --- a/src/renderer/features/artists/queries/roles-query.ts +++ b/src/renderer/features/artists/queries/roles-query.ts @@ -3,11 +3,11 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; export const useRoles = (args: QueryHookArgs) => { const { options, serverId } = args; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!serverId, diff --git a/src/renderer/features/artists/queries/top-songs-list-query.ts b/src/renderer/features/artists/queries/top-songs-list-query.ts index 19f8d20a1..e8d83ee44 100644 --- a/src/renderer/features/artists/queries/top-songs-list-query.ts +++ b/src/renderer/features/artists/queries/top-songs-list-query.ts @@ -4,12 +4,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { TopSongListQuery } from '/@/shared/types/domain/song-domain-types'; export const useTopSongsList = (args: QueryHookArgs) => { const { options, query, serverId } = args || {}; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!server?.id, diff --git a/src/renderer/features/artists/routes/album-artist-list-route.tsx b/src/renderer/features/artists/routes/album-artist-list-route.tsx index f30bff5df..1d398d148 100644 --- a/src/renderer/features/artists/routes/album-artist-list-route.tsx +++ b/src/renderer/features/artists/routes/album-artist-list-route.tsx @@ -23,7 +23,7 @@ const AlbumArtistListRoute = () => { const itemCountCheck = useAlbumArtistListCount({ options: { - cacheTime: 1000 * 60, + gcTime: 1000 * 60, staleTime: 1000 * 60, }, query: albumArtistListFilter, diff --git a/src/renderer/features/artists/routes/artist-list-route.tsx b/src/renderer/features/artists/routes/artist-list-route.tsx index 7cb874d46..19eedf91a 100644 --- a/src/renderer/features/artists/routes/artist-list-route.tsx +++ b/src/renderer/features/artists/routes/artist-list-route.tsx @@ -23,7 +23,7 @@ const ArtistListRoute = () => { const itemCountCheck = useArtistListCount({ options: { - cacheTime: 1000 * 60, + gcTime: 1000 * 60, staleTime: 1000 * 60, }, query: artistListFilter, diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index e7f7aca47..c2bd3cfc7 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -37,7 +37,7 @@ import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/ import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared'; import { AppRoute } from '/@/renderer/router/routes'; import { - getServerById, + useServerById, useAuthStore, useCurrentServer, usePlayerStore, @@ -712,7 +712,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { const item = ctx.data[0]; const songs = await controller.getSimilarSongs({ apiClientProps: { - server: getServerById(item.serverId), + server: useServerById(item.serverId), signal: undefined, }, query: { albumArtistIds: item.albumArtistIds, songId: item.id }, diff --git a/src/renderer/features/discord-rpc/use-discord-rpc.ts b/src/renderer/features/discord-rpc/use-discord-rpc.ts index 9beeb38c5..572e11961 100644 --- a/src/renderer/features/discord-rpc/use-discord-rpc.ts +++ b/src/renderer/features/discord-rpc/use-discord-rpc.ts @@ -5,15 +5,15 @@ import { useCallback, useEffect, useState } from 'react'; import { controller } from '/@/renderer/api/controller'; import { DiscordDisplayType, - getServerById, useAppStore, useDiscordSettings, + useServerById, useGeneralSettings, usePlayerStore, } from '/@/renderer/store'; import { QueueSong } from '/@/shared/types/domain/player-domain-types'; import { ServerType } from '/@/shared/types/domain/server-domain-types'; -import { PlayerStatus, PlayerStatus } from '/@/shared/types/types'; +import { PlayerStatus } from '/@/shared/types/types'; const discordRpc = isElectron() ? window.api.discordRpc : null; @@ -93,7 +93,7 @@ export const useDiscordRpc = () => { if (song.serverType === ServerType.JELLYFIN && song.imageUrl) { activity.largeImageKey = song.imageUrl; } else if (song.serverType === ServerType.NAVIDROME) { - const server = getServerById(song.serverId); + const server = useServerById(song.serverId); try { const info = await controller.getAlbumInfo({ diff --git a/src/renderer/features/genres/components/genre-list-grid-view.tsx b/src/renderer/features/genres/components/genre-list-grid-view.tsx index 7b23817bd..d6b07291d 100644 --- a/src/renderer/features/genres/components/genre-list-grid-view.tsx +++ b/src/renderer/features/genres/components/genre-list-grid-view.tsx @@ -74,15 +74,11 @@ export const GenreListGridView = ({ gridRef, itemCount }: any) => { const itemData: Genre[] = []; for (const [, data] of queriesFromCache) { - const { items, startIndex } = data || {}; + const { items, offset } = data || {}; - if (items && items.length !== 1 && startIndex !== undefined) { + if (items && items.length !== 1 && offset !== undefined) { let itemIndex = 0; - for ( - let rowIndex = startIndex; - rowIndex < startIndex + items.length; - rowIndex += 1 - ) { + for (let rowIndex = offset; rowIndex < offset + items.length; rowIndex += 1) { itemData[rowIndex] = items[itemIndex]; itemIndex += 1; } @@ -101,7 +97,7 @@ export const GenreListGridView = ({ gridRef, itemCount }: any) => { const query: GenreListQuery = { ...filter, limit: take, - startIndex: skip, + offset: skip, }; const queryKey = queryKeys.albums.list(server?.id || '', query); diff --git a/src/renderer/features/genres/queries/genre-list-query.ts b/src/renderer/features/genres/queries/genre-list-query.ts index 299e92b0f..7afa90bfa 100644 --- a/src/renderer/features/genres/queries/genre-list-query.ts +++ b/src/renderer/features/genres/queries/genre-list-query.ts @@ -4,12 +4,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { GenreListQuery } from '/@/shared/types/domain/genre-domain-types'; export const useGenreList = (args: QueryHookArgs) => { const { options, query, serverId } = args || {}; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!server, diff --git a/src/renderer/features/genres/routes/genre-list-route.tsx b/src/renderer/features/genres/routes/genre-list-route.tsx index 432f09c92..f449d3450 100644 --- a/src/renderer/features/genres/routes/genre-list-route.tsx +++ b/src/renderer/features/genres/routes/genre-list-route.tsx @@ -23,7 +23,7 @@ const GenreListRoute = () => { query: { ...filter, limit: 1, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); diff --git a/src/renderer/features/home/queries/recently-played-query.ts b/src/renderer/features/home/queries/recently-played-query.ts index 7009ebe88..418f7cd7c 100644 --- a/src/renderer/features/home/queries/recently-played-query.ts +++ b/src/renderer/features/home/queries/recently-played-query.ts @@ -3,19 +3,19 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { AlbumListQuery, AlbumListSort } from '/@/shared/types/domain/album-domain-types'; import { ListSortOrder } from '/@/shared/types/domain/shared-domain-types'; export const useRecentlyPlayed = (args: QueryHookArgs>) => { const { options, query, serverId } = args; - const server = getServerById(serverId); + const server = useServerById(serverId); const requestQuery: AlbumListQuery = { limit: 5, sortBy: AlbumListSort.RECENTLY_PLAYED, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, ...query, }; diff --git a/src/renderer/features/home/routes/home-route.tsx b/src/renderer/features/home/routes/home-route.tsx index 9cbce5cc8..94e5d82a2 100644 --- a/src/renderer/features/home/routes/home-route.tsx +++ b/src/renderer/features/home/routes/home-route.tsx @@ -40,7 +40,7 @@ const HomeRoute = () => { const feature = useAlbumList({ options: { - cacheTime: 1000 * 60, + gcTime: 1000 * 60, enabled: homeFeature, staleTime: 1000 * 60, }, @@ -48,7 +48,7 @@ const HomeRoute = () => { limit: 20, sortBy: AlbumListSort.RANDOM, sortOrder: ListSortOrder.DESC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); @@ -65,7 +65,7 @@ const HomeRoute = () => { limit: itemsPerPage, sortBy: AlbumListSort.RANDOM, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); @@ -78,7 +78,7 @@ const HomeRoute = () => { limit: itemsPerPage, sortBy: AlbumListSort.RECENTLY_PLAYED, sortOrder: ListSortOrder.DESC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); @@ -91,7 +91,7 @@ const HomeRoute = () => { limit: itemsPerPage, sortBy: AlbumListSort.RECENTLY_ADDED, sortOrder: ListSortOrder.DESC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); @@ -105,7 +105,7 @@ const HomeRoute = () => { limit: itemsPerPage, sortBy: AlbumListSort.PLAY_COUNT, sortOrder: ListSortOrder.DESC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); @@ -120,7 +120,7 @@ const HomeRoute = () => { limit: itemsPerPage, sortBy: SongListSort.PLAY_COUNT, sortOrder: ListSortOrder.DESC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }, diff --git a/src/renderer/features/lyrics/queries/lyric-query.ts b/src/renderer/features/lyrics/queries/lyric-query.ts index 74df61214..68203a054 100644 --- a/src/renderer/features/lyrics/queries/lyric-query.ts +++ b/src/renderer/features/lyrics/queries/lyric-query.ts @@ -4,7 +4,7 @@ import isElectron from 'is-electron'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById, useLyricsSettings } from '/@/renderer/store'; +import { useServerById, useLyricsSettings } from '/@/renderer/store'; import { hasFeature } from '/@/shared/api/utils'; import { FullLyricsMetadata, @@ -64,7 +64,7 @@ export const useServerLyrics = ( args: QueryHookArgs, ): UseQueryResult => { const { query, serverId } = args; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ // Note: This currently fetches for every song, even if it shouldn't have @@ -86,7 +86,7 @@ export const useSongLyricsBySong = ( ): UseQueryResult => { const { query } = args; const { fetch, preferLocalLyrics } = useLyricsSettings(); - const server = getServerById(song?.serverId); + const server = useServerById(song?.serverId); return useQuery({ cacheTime: Infinity, diff --git a/src/renderer/features/player/components/shuffle-all-modal.tsx b/src/renderer/features/player/components/shuffle-all-modal.tsx index e1a189a01..4369d83be 100644 --- a/src/renderer/features/player/components/shuffle-all-modal.tsx +++ b/src/renderer/features/player/components/shuffle-all-modal.tsx @@ -258,7 +258,7 @@ export const openShuffleAllModal = async ( query: { sortBy: GenreListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, }), queryKey: queryKeys.genres.list(server?.id), diff --git a/src/renderer/features/player/mutations/scrobble-mutation.ts b/src/renderer/features/player/mutations/scrobble-mutation.ts index b8dcc0f46..70c7d4978 100644 --- a/src/renderer/features/player/mutations/scrobble-mutation.ts +++ b/src/renderer/features/player/mutations/scrobble-mutation.ts @@ -3,7 +3,7 @@ import { AxiosError } from 'axios'; import { api } from '/@/renderer/api'; import { MutationOptions } from '/@/renderer/lib/react-query'; -import { getServerById, useIncrementQueuePlayCount } from '/@/renderer/store'; +import { useServerById, useIncrementQueuePlayCount } from '/@/renderer/store'; import { usePlayEvent } from '/@/renderer/store/event.store'; import { ScrobbleRequest, ScrobbleResponse } from '/@/shared/types/domain/user-domain-types'; @@ -18,7 +18,7 @@ export const useSendScrobble = (options?: MutationOptions) => { null >({ mutationFn: (args) => { - const server = getServerById(args.serverId); + const server = useServerById(args.serverId); if (!server) throw new Error('Server not found'); return api.controller.scrobble({ ...args, apiClientProps: { server } }); }, diff --git a/src/renderer/features/player/utils.ts b/src/renderer/features/player/utils.ts index 0ba49ab14..9b227289c 100644 --- a/src/renderer/features/player/utils.ts +++ b/src/renderer/features/player/utils.ts @@ -62,7 +62,7 @@ export const getAlbumSongsById = async (args: { albumIds: id, sortBy: SongListSort.ALBUM, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, ...query, }; @@ -98,7 +98,7 @@ export const getGenreSongsById = async (args: { const data: SongListResponse = { items: [], - startIndex: 0, + offset: 0, totalRecordCount: 0, }; for (const genreId of id) { @@ -106,7 +106,7 @@ export const getGenreSongsById = async (args: { genreIds: [genreId], sortBy: SongListSort.GENRE, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, ...query, }; @@ -150,7 +150,7 @@ export const getAlbumArtistSongsById = async (args: { albumArtistIds: id || [], sortBy: SongListSort.ALBUM_ARTIST, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, ...query, }; @@ -187,7 +187,7 @@ export const getArtistSongsById = async (args: { artistIds: id, sortBy: SongListSort.ALBUM, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, ...query, }; @@ -222,7 +222,7 @@ export const getSongsByQuery = async (args: { const queryFilter: SongListQuery = { sortBy: SongListSort.ALBUM, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, ...query, }; @@ -279,7 +279,7 @@ export const getSongById = async (args: { return { items: [res], - startIndex: 0, + offset: 0, totalRecordCount: 1, }; }; diff --git a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx index 74e0192a3..646499249 100644 --- a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx +++ b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx @@ -46,7 +46,7 @@ export const AddToPlaylistContextModal = ({ }, sortBy: PlaylistListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); @@ -72,7 +72,7 @@ export const AddToPlaylistContextModal = ({ albumIds: [albumId], sortBy: SongListSort.ALBUM, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }; const queryKey = queryKeys.songs.list(server?.id || '', query); @@ -90,7 +90,7 @@ export const AddToPlaylistContextModal = ({ artistIds: [artistId], sortBy: SongListSort.ARTIST, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }; const queryKey = queryKeys.songs.list(server?.id || '', query); diff --git a/src/renderer/features/playlists/components/playlist-list-grid-view.tsx b/src/renderer/features/playlists/components/playlist-list-grid-view.tsx index 85e64486e..928deaaac 100644 --- a/src/renderer/features/playlists/components/playlist-list-grid-view.tsx +++ b/src/renderer/features/playlists/components/playlist-list-grid-view.tsx @@ -88,15 +88,11 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie const itemData: Playlist[] = []; for (const [, data] of queriesFromCache) { - const { items, startIndex } = data || {}; + const { items, offset } = data || {}; - if (items && items.length !== 1 && startIndex !== undefined) { + if (items && items.length !== 1 && offset !== undefined) { let itemIndex = 0; - for ( - let rowIndex = startIndex; - rowIndex < startIndex + items.length; - rowIndex += 1 - ) { + for (let rowIndex = offset; rowIndex < offset + items.length; rowIndex += 1) { itemData[rowIndex] = items[itemIndex]; itemIndex += 1; } @@ -116,7 +112,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie limit: take, ...filter, _custom: {}, - startIndex: skip, + offset: skip, }; const queryKey = queryKeys.playlists.list(server?.id || '', query); diff --git a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx index 9a8c144d1..5b21c2203 100644 --- a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx @@ -160,7 +160,7 @@ export const PlaylistListHeaderFilters = ({ }, }, limit: take, - startIndex: skip, + offset: skip, ...filters, }; @@ -213,7 +213,7 @@ export const PlaylistListHeaderFilters = ({ }, query: { limit, - startIndex, + offset, ...pageFilters, }, }), diff --git a/src/renderer/features/playlists/components/playlist-query-builder.tsx b/src/renderer/features/playlists/components/playlist-query-builder.tsx index ccc84bb20..8d8995d3d 100644 --- a/src/renderer/features/playlists/components/playlist-query-builder.tsx +++ b/src/renderer/features/playlists/components/playlist-query-builder.tsx @@ -111,7 +111,7 @@ export const PlaylistQueryBuilder = forwardRef( ); const { data: playlists } = usePlaylistList({ - query: { sortBy: PlaylistListSort.NAME, sortOrder: ListSortOrder.ASC, startIndex: 0 }, + query: { sortBy: PlaylistListSort.NAME, sortOrder: ListSortOrder.ASC, offset: 0 }, serverId: server?.id, }); diff --git a/src/renderer/features/playlists/components/update-playlist-form.tsx b/src/renderer/features/playlists/components/update-playlist-form.tsx index 28b841a51..e7c833cd3 100644 --- a/src/renderer/features/playlists/components/update-playlist-form.tsx +++ b/src/renderer/features/playlists/components/update-playlist-form.tsx @@ -167,7 +167,7 @@ export const openUpdatePlaylistModal = async (args: { const query: UserListQuery = { sortBy: UserListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }; if (!server) return; diff --git a/src/renderer/features/playlists/mutations/add-to-playlist-mutation.ts b/src/renderer/features/playlists/mutations/add-to-playlist-mutation.ts index 144c6e479..1a4f3b3f8 100644 --- a/src/renderer/features/playlists/mutations/add-to-playlist-mutation.ts +++ b/src/renderer/features/playlists/mutations/add-to-playlist-mutation.ts @@ -4,7 +4,7 @@ import { AxiosError } from 'axios'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { AddToPlaylistArgs, AddToPlaylistResponse, @@ -21,7 +21,7 @@ export const useAddToPlaylist = (args: MutationHookArgs) => { null >({ mutationFn: (args) => { - const server = getServerById(args.serverId); + const server = useServerById(args.serverId); if (!server) throw new Error('Server not found'); return api.controller.addToPlaylist({ ...args, apiClientProps: { server } }); }, diff --git a/src/renderer/features/playlists/mutations/create-playlist-mutation.ts b/src/renderer/features/playlists/mutations/create-playlist-mutation.ts index 7e55683b1..34be65736 100644 --- a/src/renderer/features/playlists/mutations/create-playlist-mutation.ts +++ b/src/renderer/features/playlists/mutations/create-playlist-mutation.ts @@ -4,7 +4,7 @@ import { AxiosError } from 'axios'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { CreatePlaylistResponse } from '/@/shared/types/domain/playlist-domain-types'; export const useCreatePlaylist = (args: MutationHookArgs) => { @@ -18,12 +18,12 @@ export const useCreatePlaylist = (args: MutationHookArgs) => { null >({ mutationFn: (args) => { - const server = getServerById(args.serverId); + const server = useServerById(args.serverId); if (!server) throw new Error('Server not found'); return api.controller.createPlaylist({ ...args, apiClientProps: { server } }); }, onSuccess: (_args, variables) => { - const server = getServerById(variables.serverId); + const server = useServerById(variables.serverId); if (server) { queryClient.invalidateQueries(queryKeys.playlists.list(server.id)); } diff --git a/src/renderer/features/playlists/mutations/delete-playlist-mutation.ts b/src/renderer/features/playlists/mutations/delete-playlist-mutation.ts index 6655fa6f3..e4cd45702 100644 --- a/src/renderer/features/playlists/mutations/delete-playlist-mutation.ts +++ b/src/renderer/features/playlists/mutations/delete-playlist-mutation.ts @@ -4,7 +4,7 @@ import { AxiosError } from 'axios'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById, useCurrentServer } from '/@/renderer/store'; +import { useServerById, useCurrentServer } from '/@/renderer/store'; import { DeletePlaylistResponse } from '/@/shared/types/domain/playlist-domain-types'; export const useDeletePlaylist = (args: MutationHookArgs) => { @@ -19,7 +19,7 @@ export const useDeletePlaylist = (args: MutationHookArgs) => { null >({ mutationFn: (args) => { - const server = getServerById(args.serverId); + const server = useServerById(args.serverId); if (!server) throw new Error('Server not found'); return api.controller.deletePlaylist({ ...args, apiClientProps: { server } }); }, diff --git a/src/renderer/features/playlists/mutations/remove-from-playlist-mutation.ts b/src/renderer/features/playlists/mutations/remove-from-playlist-mutation.ts index 098577e21..69b9d24db 100644 --- a/src/renderer/features/playlists/mutations/remove-from-playlist-mutation.ts +++ b/src/renderer/features/playlists/mutations/remove-from-playlist-mutation.ts @@ -4,7 +4,7 @@ import { AxiosError, AxiosError } from 'axios'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { MutationOptions } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { RemoveFromPlaylistResponse } from '/@/shared/types/domain/playlist-domain-types'; export const useRemoveFromPlaylist = (options?: MutationOptions) => { @@ -17,7 +17,7 @@ export const useRemoveFromPlaylist = (options?: MutationOptions) => { null >({ mutationFn: (args) => { - const server = getServerById(args.serverId); + const server = useServerById(args.serverId); if (!server) throw new Error('Server not found'); return api.controller.removeFromPlaylist({ ...args, apiClientProps: { server } }); }, diff --git a/src/renderer/features/playlists/mutations/update-playlist-mutation.ts b/src/renderer/features/playlists/mutations/update-playlist-mutation.ts index fd5183dcc..43f6c0573 100644 --- a/src/renderer/features/playlists/mutations/update-playlist-mutation.ts +++ b/src/renderer/features/playlists/mutations/update-playlist-mutation.ts @@ -4,7 +4,7 @@ import { AxiosError } from 'axios'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { UpdatePlaylistResponse } from '/@/shared/types/domain/playlist-domain-types'; export const useUpdatePlaylist = (args: MutationHookArgs) => { @@ -18,7 +18,7 @@ export const useUpdatePlaylist = (args: MutationHookArgs) => { null >({ mutationFn: (args) => { - const server = getServerById(args.serverId); + const server = useServerById(args.serverId); if (!server) throw new Error('Server not found'); return api.controller.updatePlaylist({ ...args, apiClientProps: { server } }); }, diff --git a/src/renderer/features/playlists/queries/playlist-detail-query.ts b/src/renderer/features/playlists/queries/playlist-detail-query.ts index 4bb687d71..0e9830dbe 100644 --- a/src/renderer/features/playlists/queries/playlist-detail-query.ts +++ b/src/renderer/features/playlists/queries/playlist-detail-query.ts @@ -4,12 +4,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { PlaylistDetailQuery } from '/@/shared/types/domain/playlist-domain-types'; export const usePlaylistDetail = (args: QueryHookArgs) => { const { options, query, serverId } = args || {}; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!server?.id, diff --git a/src/renderer/features/playlists/queries/playlist-list-query.ts b/src/renderer/features/playlists/queries/playlist-list-query.ts index 38d8296a8..234599778 100644 --- a/src/renderer/features/playlists/queries/playlist-list-query.ts +++ b/src/renderer/features/playlists/queries/playlist-list-query.ts @@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { PlaylistListQuery } from '/@/shared/types/domain/playlist-domain-types'; export const usePlaylistList = (args: { @@ -13,7 +13,7 @@ export const usePlaylistList = (args: { serverId?: string; }) => { const { options, query, serverId } = args; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ cacheTime: 1000 * 60 * 60, diff --git a/src/renderer/features/playlists/queries/playlist-song-list-query.ts b/src/renderer/features/playlists/queries/playlist-song-list-query.ts index 4c46e438a..7dcf049e8 100644 --- a/src/renderer/features/playlists/queries/playlist-song-list-query.ts +++ b/src/renderer/features/playlists/queries/playlist-song-list-query.ts @@ -4,12 +4,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { PlaylistSongListQuery } from '/@/shared/types/domain/playlist-domain-types'; export const usePlaylistSongList = (args: QueryHookArgs) => { const { options, query, serverId } = args || {}; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!server, diff --git a/src/renderer/features/playlists/routes/playlist-list-route.tsx b/src/renderer/features/playlists/routes/playlist-list-route.tsx index bb8b4cf3e..948ee0a6e 100644 --- a/src/renderer/features/playlists/routes/playlist-list-route.tsx +++ b/src/renderer/features/playlists/routes/playlist-list-route.tsx @@ -26,7 +26,7 @@ const PlaylistListRoute = () => { const itemCountCheck = usePlaylistList({ options: { - cacheTime: 1000 * 60 * 60 * 2, + gcTime: 1000 * 60 * 60 * 2, staleTime: 1000 * 60 * 60 * 2, }, query: { @@ -34,7 +34,7 @@ const PlaylistListRoute = () => { limit: 1, sortBy: PlaylistListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); diff --git a/src/renderer/features/search/queries/search-query.ts b/src/renderer/features/search/queries/search-query.ts index b71c2dbee..9e18b9bf5 100644 --- a/src/renderer/features/search/queries/search-query.ts +++ b/src/renderer/features/search/queries/search-query.ts @@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { SearchQuery } from '/@/shared/types/domain/search-domain-types'; export const useSearch = (args: QueryHookArgs) => { const { options, query, serverId } = args; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!serverId, diff --git a/src/renderer/features/servers/components/add-server-form.tsx b/src/renderer/features/servers/components/add-server-form.tsx index 8f7437fcc..ed86f192a 100644 --- a/src/renderer/features/servers/components/add-server-form.tsx +++ b/src/renderer/features/servers/components/add-server-form.tsx @@ -6,7 +6,8 @@ import { nanoid } from 'nanoid/non-secure'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { api } from '/@/renderer/api'; +import { useSignIn } from '../mutations/sign-in-mutation'; + import JellyfinIcon from '/@/renderer/features/servers/assets/jellyfin.png'; import NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png'; import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png'; @@ -20,7 +21,6 @@ import { Stack } from '/@/shared/components/stack/stack'; import { TextInput } from '/@/shared/components/text-input/text-input'; import { Text } from '/@/shared/components/text/text'; import { toast } from '/@/shared/components/toast/toast'; -import { AuthenticationResponse } from '/@/shared/types/domain/auth-domain-types'; import { ServerType } from '/@/shared/types/domain/server-domain-types'; import { toServerType } from '/@/shared/types/types'; @@ -59,6 +59,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { const focusTrapRef = useFocusTrap(true); const [isLoading, setIsLoading] = useState(false); const { addServer, setCurrentServer } = useAuthStoreActions(); + const { isPending, mutateAsync: signIn } = useSignIn(); const form = useForm({ initialValues: { @@ -87,7 +88,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { const isSubmitDisabled = !form.values.name || !form.values.url || !form.values.username; const handleSubmit = form.onSubmit(async (values) => { - const authFunction = api.controller.authenticate; + const authFunction = signIn; if (!authFunction) { return toast.error({ @@ -97,35 +98,33 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { try { setIsLoading(true); - const data: AuthenticationResponse | undefined = await authFunction( - values.url, - { - legacy: values.legacyAuth, - password: values.password, - username: values.username, + const [err, response] = await signIn({ + request: { + body: { + password: values.password, + username: values.username, + }, + url: values.url, }, - values.type as ServerType, - ); + serverType: values.type as ServerType, + }); - if (!data) { + if (err || !response) { return toast.error({ message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }), }); } const serverItem = { - credential: data.credential, + credential: response.credential, id: nanoid(), name: values.name, - ndCredential: data.ndCredential, type: values.type as ServerType, url: values.url.replace(/\/$/, ''), - userId: data.userId, - username: data.username, + username: response.username, }; addServer(serverItem); - setCurrentServer(serverItem); closeAllModals(); toast.success({ diff --git a/src/renderer/features/servers/mutations/sign-in-mutation.ts b/src/renderer/features/servers/mutations/sign-in-mutation.ts new file mode 100644 index 000000000..aba4881f0 --- /dev/null +++ b/src/renderer/features/servers/mutations/sign-in-mutation.ts @@ -0,0 +1,16 @@ +import { useMutation } from '@tanstack/react-query'; + +import { api } from '/@/renderer/api/api-controller'; +import { AuthenticationRequest } from '/@/shared/types/domain/auth-domain-types'; +import { ServerType } from '/@/shared/types/domain/server-domain-types'; + +export function useSignIn() { + const mutation = useMutation({ + mutationFn: (args: { request: AuthenticationRequest; serverType: ServerType }) => { + const result = api.authenticate(args.serverType)(args.request); + return result; + }, + }); + + return mutation; +} diff --git a/src/renderer/features/shared/mutations/create-favorite-mutation.ts b/src/renderer/features/shared/mutations/create-favorite-mutation.ts index 00effdf53..8c1dd3ce2 100644 --- a/src/renderer/features/shared/mutations/create-favorite-mutation.ts +++ b/src/renderer/features/shared/mutations/create-favorite-mutation.ts @@ -5,10 +5,10 @@ import isElectron from 'is-electron'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store'; +import { useServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store'; import { useFavoriteEvent } from '/@/renderer/store/event.store'; import { AlbumDetailResponse } from '/@/shared/types/domain/album-domain-types'; -import { AlbumArtistDetailResponse } from '/@/shared/types/domain/artist-domain-types'; +import { ArtistDetailResponse } from '/@/shared/types/domain/artist-domain-types'; import { LibraryItem } from '/@/shared/types/domain/shared-domain-types'; import { FavoriteResponse } from '/@/shared/types/domain/user-domain-types'; @@ -28,7 +28,7 @@ export const useCreateFavorite = (args: MutationHookArgs) => { null >({ mutationFn: (args) => { - const server = getServerById(args.serverId); + const server = useServerById(args.serverId); if (!server) throw new Error('Server not found'); return api.controller.createFavorite({ ...args, apiClientProps: { server } }); }, @@ -72,10 +72,10 @@ export const useCreateFavorite = (args: MutationHookArgs) => { id: variables.query.id[0], }); - const previous = queryClient.getQueryData(queryKey); + const previous = queryClient.getQueryData(queryKey); if (previous) { - queryClient.setQueryData(queryKey, { + queryClient.setQueryData(queryKey, { ...previous, userFavorite: true, }); diff --git a/src/renderer/features/shared/mutations/delete-favorite-mutation.ts b/src/renderer/features/shared/mutations/delete-favorite-mutation.ts index 81169cb97..d6ef2c554 100644 --- a/src/renderer/features/shared/mutations/delete-favorite-mutation.ts +++ b/src/renderer/features/shared/mutations/delete-favorite-mutation.ts @@ -5,10 +5,10 @@ import isElectron from 'is-electron'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store'; +import { useServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store'; import { useFavoriteEvent } from '/@/renderer/store/event.store'; import { AlbumDetailResponse } from '/@/shared/types/domain/album-domain-types'; -import { AlbumArtistDetailResponse } from '/@/shared/types/domain/artist-domain-types'; +import { ArtistDetailResponse } from '/@/shared/types/domain/artist-domain-types'; import { LibraryItem } from '/@/shared/types/domain/shared-domain-types'; import { FavoriteResponse } from '/@/shared/types/domain/user-domain-types'; @@ -28,7 +28,7 @@ export const useDeleteFavorite = (args: MutationHookArgs) => { null >({ mutationFn: (args) => { - const server = getServerById(args.serverId); + const server = useServerById(args.serverId); if (!server) throw new Error('Server not found'); return api.controller.deleteFavorite({ ...args, apiClientProps: { server } }); }, @@ -71,10 +71,10 @@ export const useDeleteFavorite = (args: MutationHookArgs) => { const queryKey = queryKeys.albumArtists.detail(serverId, { id: variables.query.id[0], }); - const previous = queryClient.getQueryData(queryKey); + const previous = queryClient.getQueryData(queryKey); if (previous) { - queryClient.setQueryData(queryKey, { + queryClient.setQueryData(queryKey, { ...previous, userFavorite: false, }); diff --git a/src/renderer/features/shared/mutations/set-rating-mutation.ts b/src/renderer/features/shared/mutations/set-rating-mutation.ts index e177c9ebf..b6b200d0b 100644 --- a/src/renderer/features/shared/mutations/set-rating-mutation.ts +++ b/src/renderer/features/shared/mutations/set-rating-mutation.ts @@ -5,10 +5,10 @@ import isElectron from 'is-electron'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById, useSetAlbumListItemDataById, useSetQueueRating } from '/@/renderer/store'; +import { useServerById, useSetAlbumListItemDataById, useSetQueueRating } from '/@/renderer/store'; import { useRatingEvent } from '/@/renderer/store/event.store'; import { Album, AlbumDetailResponse } from '/@/shared/types/domain/album-domain-types'; -import { AlbumArtistDetailResponse } from '/@/shared/types/domain/artist-domain-types'; +import { ArtistDetailResponse } from '/@/shared/types/domain/artist-domain-types'; import { AnyLibraryItems, LibraryItem } from '/@/shared/types/domain/shared-domain-types'; import { RatingResponse, SetRatingRequest } from '/@/shared/types/domain/user-domain-types'; @@ -28,7 +28,7 @@ export const useSetRating = (args: MutationHookArgs) => { { previous: undefined | { items: AnyLibraryItems } } >({ mutationFn: (args) => { - const server = getServerById(args.serverId); + const server = useServerById(args.serverId); if (!server) throw new Error('Server not found'); return api.controller.setRating({ ...args, apiClientProps: { server } }); }, @@ -104,9 +104,9 @@ export const useSetRating = (args: MutationHookArgs) => { const queryKey = queryKeys.albumArtists.detail(serverId || '', { id: albumArtistId, }); - const previous = queryClient.getQueryData(queryKey); + const previous = queryClient.getQueryData(queryKey); if (previous) { - queryClient.setQueryData(queryKey, { + queryClient.setQueryData(queryKey, { ...previous, userRating: variables.query.rating, }); diff --git a/src/renderer/features/shared/queries/music-folders-query.ts b/src/renderer/features/shared/queries/music-folders-query.ts index 510c73599..c6d85e044 100644 --- a/src/renderer/features/shared/queries/music-folders-query.ts +++ b/src/renderer/features/shared/queries/music-folders-query.ts @@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { ServerMusicFolderListQuery } from '/@/shared/types/domain/server-domain-types'; export const useMusicFolders = (args: QueryHookArgs) => { const { options, serverId } = args || {}; - const server = getServerById(serverId); + const server = useServerById(serverId); const query = useQuery({ enabled: !!server, diff --git a/src/renderer/features/sharing/mutations/share-item-mutation.ts b/src/renderer/features/sharing/mutations/share-item-mutation.ts index 918d28a57..d8b84da25 100644 --- a/src/renderer/features/sharing/mutations/share-item-mutation.ts +++ b/src/renderer/features/sharing/mutations/share-item-mutation.ts @@ -3,7 +3,7 @@ import { AxiosError } from 'axios'; import { api } from '/@/renderer/api'; import { MutationHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { AnyLibraryItems } from '/@/shared/types/domain/shared-domain-types'; import { ShareItemRequest, ShareItemResponse } from '/@/shared/types/domain/user-domain-types'; @@ -17,7 +17,7 @@ export const useShareItem = (args: MutationHookArgs) => { { previous: undefined | { items: AnyLibraryItems } } >({ mutationFn: (args) => { - const server = getServerById(args.serverId); + const server = useServerById(args.serverId); if (!server) throw new Error('Server not found'); return api.controller.shareItem({ ...args, apiClientProps: { server } }); }, diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx index 38f377760..4c6c14af7 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx @@ -142,7 +142,7 @@ export const SidebarPlaylistList = () => { query: { sortBy: PlaylistListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); @@ -258,7 +258,7 @@ export const SidebarSharedPlaylistList = () => { query: { sortBy: PlaylistListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); diff --git a/src/renderer/features/similar-songs/components/similar-songs-list.tsx b/src/renderer/features/similar-songs/components/similar-songs-list.tsx index 73e4a3624..0d0484274 100644 --- a/src/renderer/features/similar-songs/components/similar-songs-list.tsx +++ b/src/renderer/features/similar-songs/components/similar-songs-list.tsx @@ -29,7 +29,7 @@ export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListPr const songQuery = useSimilarSongs({ options: { - cacheTime: 1000 * 60 * 2, + gcTime: 1000 * 60 * 2, staleTime: 1000 * 60 * 1, }, query: { albumArtistIds: song.albumArtists.map((art) => art.id), count, songId: song.id }, diff --git a/src/renderer/features/similar-songs/queries/similar-song-queries.tsx b/src/renderer/features/similar-songs/queries/similar-song-queries.tsx index 8d7fca687..c11f9b5a5 100644 --- a/src/renderer/features/similar-songs/queries/similar-song-queries.tsx +++ b/src/renderer/features/similar-songs/queries/similar-song-queries.tsx @@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { SimilarSongsQuery } from '/@/shared/types/domain/song-domain-types'; export const useSimilarSongs = (args: QueryHookArgs) => { const { options, query, serverId } = args || {}; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!server, diff --git a/src/renderer/features/songs/components/jellyfin-song-filters.tsx b/src/renderer/features/songs/components/jellyfin-song-filters.tsx index 39f178c57..f042438c0 100644 --- a/src/renderer/features/songs/components/jellyfin-song-filters.tsx +++ b/src/renderer/features/songs/components/jellyfin-song-filters.tsx @@ -42,7 +42,7 @@ export const JellyfinSongFilters = ({ musicFolderId: filter?.musicFolderId, sortBy: GenreListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId, }); diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index 90ea8f32c..a62513ebb 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -40,7 +40,7 @@ export const NavidromeSongFilters = ({ query: { sortBy: GenreListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId, }); diff --git a/src/renderer/features/songs/components/song-list-grid-view.tsx b/src/renderer/features/songs/components/song-list-grid-view.tsx index 74b96143c..e8db2b0c0 100644 --- a/src/renderer/features/songs/components/song-list-grid-view.tsx +++ b/src/renderer/features/songs/components/song-list-grid-view.tsx @@ -148,15 +148,11 @@ export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps) const itemData: Song[] = []; for (const [, data] of queriesFromCache) { - const { items, startIndex } = data || {}; + const { items, offset } = data || {}; - if (items && items.length !== 1 && startIndex !== undefined) { + if (items && items.length !== 1 && offset !== undefined) { let itemIndex = 0; - for ( - let rowIndex = startIndex; - rowIndex < startIndex + items.length; - rowIndex += 1 - ) { + for (let rowIndex = offset; rowIndex < offset + items.length; rowIndex += 1) { itemData[rowIndex] = items[itemIndex]; itemIndex += 1; } @@ -177,7 +173,7 @@ export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps) limit: take, ...filter, ...customFilters, - startIndex: skip, + offset: skip, }; const queryKey = queryKeys.songs.list(server?.id || '', query, id); diff --git a/src/renderer/features/songs/components/subsonic-song-filter.tsx b/src/renderer/features/songs/components/subsonic-song-filter.tsx index d67660ec3..5bd4fc585 100644 --- a/src/renderer/features/songs/components/subsonic-song-filter.tsx +++ b/src/renderer/features/songs/components/subsonic-song-filter.tsx @@ -37,7 +37,7 @@ export const SubsonicSongFilters = ({ query: { sortBy: GenreListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId, }); diff --git a/src/renderer/features/songs/queries/song-list-count-query.ts b/src/renderer/features/songs/queries/song-list-count-query.ts index 8eb5fb709..84620bcc0 100644 --- a/src/renderer/features/songs/queries/song-list-count-query.ts +++ b/src/renderer/features/songs/queries/song-list-count-query.ts @@ -4,12 +4,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { SongListQuery } from '/@/shared/types/domain/song-domain-types'; export const useSongListCount = (args: QueryHookArgs) => { const { options, query, serverId } = args; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!serverId, diff --git a/src/renderer/features/songs/queries/song-list-query.ts b/src/renderer/features/songs/queries/song-list-query.ts index e18d4985b..3a24e7ed0 100644 --- a/src/renderer/features/songs/queries/song-list-query.ts +++ b/src/renderer/features/songs/queries/song-list-query.ts @@ -4,12 +4,12 @@ import { useQuery } from '@tanstack/react-query'; import { controller } from '/@/renderer/api/controller'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { SongListQuery } from '/@/shared/types/domain/song-domain-types'; export const useSongList = (args: QueryHookArgs, imageSize?: number) => { const { options, query, serverId } = args || {}; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!server?.id, diff --git a/src/renderer/features/songs/routes/song-list-route.tsx b/src/renderer/features/songs/routes/song-list-route.tsx index 3d5b2ccad..f25a9227c 100644 --- a/src/renderer/features/songs/routes/song-list-route.tsx +++ b/src/renderer/features/songs/routes/song-list-route.tsx @@ -50,13 +50,13 @@ const TrackListRoute = () => { const genreList = useGenreList({ options: { - cacheTime: 1000 * 60 * 60, + gcTime: 1000 * 60 * 60, enabled: !!genreId, }, query: { sortBy: GenreListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, serverId: server?.id, }); @@ -72,7 +72,7 @@ const TrackListRoute = () => { const itemCountCheck = useSongListCount({ options: { - cacheTime: 1000 * 60, + gcTime: 1000 * 60, staleTime: 1000 * 60, }, query: songListFilter, @@ -85,7 +85,7 @@ const TrackListRoute = () => { async (args: { initialSongId?: string; playType: Play }) => { if (!itemCount || itemCount === 0) return; const { initialSongId, playType } = args; - const query: SongListQuery = { ...songListFilter, limit: itemCount, startIndex: 0 }; + const query: SongListQuery = { ...songListFilter, limit: itemCount, offset: 0 }; if (albumArtistId) { handlePlayQueueAdd?.({ diff --git a/src/renderer/features/tag/queries/use-tag-list.ts b/src/renderer/features/tag/queries/use-tag-list.ts index 88cdff35f..352a27619 100644 --- a/src/renderer/features/tag/queries/use-tag-list.ts +++ b/src/renderer/features/tag/queries/use-tag-list.ts @@ -3,14 +3,14 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { hasFeature } from '/@/shared/api/utils'; import { ServerFeature } from '/@/shared/types/domain/server-domain-types'; import { TagQuery } from '/@/shared/types/domain/tag-domain-types'; export const useTagList = (args: QueryHookArgs) => { const { options, query, serverId } = args || {}; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!server && hasFeature(server, ServerFeature.TAGS), diff --git a/src/renderer/features/users/queries/user-list-query.ts b/src/renderer/features/users/queries/user-list-query.ts index 44c8ab513..1f711143d 100644 --- a/src/renderer/features/users/queries/user-list-query.ts +++ b/src/renderer/features/users/queries/user-list-query.ts @@ -4,12 +4,12 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { getServerById } from '/@/renderer/store'; +import { useServerById } from '/@/renderer/store'; import { UserListQuery } from '/@/shared/types/domain/user-domain-types'; export const useUserList = (args: QueryHookArgs) => { const { options, query, serverId } = args || {}; - const server = getServerById(serverId); + const server = useServerById(serverId); return useQuery({ enabled: !!server, diff --git a/src/renderer/hooks/use-server-authenticated.ts b/src/renderer/hooks/use-server-authenticated.ts index 9f0861e6f..1e3adf9bb 100644 --- a/src/renderer/hooks/use-server-authenticated.ts +++ b/src/renderer/hooks/use-server-authenticated.ts @@ -6,9 +6,9 @@ import { api } from '/@/renderer/api'; import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store'; import { toast } from '/@/shared/components/toast/toast'; import { ServerListItem, ServerType } from '/@/shared/types/domain/server-domain-types'; +import { ListSortOrder } from '/@/shared/types/domain/shared-domain-types'; import { SongListSort } from '/@/shared/types/domain/song-domain-types'; import { AuthState } from '/@/shared/types/types'; -import { ListSortOrder } from '/@/shared/types/domain/shared-domain-types'; const localSettings = isElectron() ? window.api.localSettings : null; @@ -33,7 +33,7 @@ export const useServerAuthenticated = () => { limit: 1, sortBy: SongListSort.NAME, sortOrder: ListSortOrder.ASC, - startIndex: 0, + offset: 0, }, }); diff --git a/src/renderer/lib/react-query.ts b/src/renderer/lib/react-query.ts index 0f58ae049..3cd563750 100644 --- a/src/renderer/lib/react-query.ts +++ b/src/renderer/lib/react-query.ts @@ -22,16 +22,12 @@ const queryConfig: DefaultOptions = { retry: process.env.NODE_ENV === 'production', }, queries: { - cacheTime: 1000 * 60 * 3, - onError: (err) => { - console.error('react query error:', err); - }, + gcTime: 1000 * 60 * 3, + refetchOnMount: false, + refetchOnReconnect: false, refetchOnWindowFocus: false, retry: process.env.NODE_ENV === 'production', staleTime: 1000 * 5, - useErrorBoundary: (error: any) => { - return error?.response?.status >= 500; - }, }, }; @@ -41,9 +37,8 @@ export const queryClient = new QueryClient({ }); export type InfiniteQueryOptions = { - cacheTime?: UseInfiniteQueryOptions['cacheTime']; enabled?: UseInfiniteQueryOptions['enabled']; - keepPreviousData?: UseInfiniteQueryOptions['keepPreviousData']; + gcTime?: UseInfiniteQueryOptions['gcTime']; meta?: UseInfiniteQueryOptions['meta']; onError?: (err: any) => void; onSettled?: any; @@ -55,7 +50,6 @@ export type InfiniteQueryOptions = { retry?: UseInfiniteQueryOptions['retry']; retryDelay?: UseInfiniteQueryOptions['retryDelay']; staleTime?: UseInfiniteQueryOptions['staleTime']; - suspense?: UseInfiniteQueryOptions['suspense']; useErrorBoundary?: boolean; }; @@ -80,9 +74,8 @@ export type QueryHookArgs = { }; export type QueryOptions = { - cacheTime?: UseQueryOptions['cacheTime']; enabled?: UseQueryOptions['enabled']; - keepPreviousData?: UseQueryOptions['keepPreviousData']; + gcTime?: UseQueryOptions['gcTime']; meta?: UseQueryOptions['meta']; onError?: (err: any) => void; onSettled?: any; @@ -94,6 +87,5 @@ export type QueryOptions = { retry?: UseQueryOptions['retry']; retryDelay?: UseQueryOptions['retryDelay']; staleTime?: UseQueryOptions['staleTime']; - suspense?: UseQueryOptions['suspense']; useErrorBoundary?: boolean; }; diff --git a/src/renderer/store/auth.store.ts b/src/renderer/store/auth.store.ts index 052746e12..b94b54924 100644 --- a/src/renderer/store/auth.store.ts +++ b/src/renderer/store/auth.store.ts @@ -1,84 +1,83 @@ import merge from 'lodash/merge'; import { nanoid } from 'nanoid/non-secure'; -import { devtools, persist } from 'zustand/middleware'; +import { create } from 'zustand'; +import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; -import { createWithEqualityFn } from 'zustand/traditional'; import { useAlbumArtistListDataStore } from '/@/renderer/store/album-artist-list-data.store'; import { useAlbumListDataStore } from '/@/renderer/store/album-list-data.store'; import { useListStore } from '/@/renderer/store/list.store'; +import { createSelectors } from '/@/renderer/store/utils'; import { ServerListItem } from '/@/shared/types/domain/server-domain-types'; export interface AuthSlice extends AuthState { - actions: { - addServer: (args: ServerListItem) => void; - deleteServer: (id: string) => void; - getServer: (id: string) => null | ServerListItem; - setCurrentServer: (server: null | ServerListItem) => void; - updateServer: (id: string, args: Partial) => void; - }; + actions: Actions; } export interface AuthState { - currentServer: null | ServerListItem; + currentServerId: null | string; deviceId: string; serverList: Record; } -export const useAuthStore = createWithEqualityFn()( +interface Actions { + addServer: (args: ServerListItem) => void; + deleteServer: (id: string) => void; + setCurrentServer: (server: null | ServerListItem) => void; + updateServer: (id: string, args: Partial) => void; +} + +const authStoreBase = create()( persist( devtools( - immer((set, get) => ({ - actions: { - addServer: (args) => { - set((state) => { - state.serverList[args.id] = args; - }); - }, - deleteServer: (id) => { - set((state) => { - delete state.serverList[id]; + subscribeWithSelector( + immer((set) => ({ + actions: { + addServer: (args) => { + set((state) => { + state.serverList[args.id] = args; + state.currentServerId = args.id; + }); + }, + deleteServer: (id) => { + set((state) => { + delete state.serverList[id]; - if (state.currentServer?.id === id) { - state.currentServer = null; - } - }); - }, - getServer: (id) => { - const server = get().serverList[id]; - if (server) return server; - return null; - }, - setCurrentServer: (server) => { - set((state) => { - state.currentServer = server; + if (state.currentServerId === id) { + state.currentServerId = null; + } + }); + }, + setCurrentServer: (server) => { + set((state) => { + state.currentServerId = server?.id || null; - if (server) { - // Reset list filters - useListStore.getState()._actions.resetFilter(); + if (server) { + // Reset list filters + useListStore.getState()._actions.resetFilter(); - // Reset persisted grid list stores - useAlbumListDataStore.getState().actions.setItemData([]); - useAlbumArtistListDataStore.getState().actions.setItemData([]); - } - }); - }, - updateServer: (id: string, args: Partial) => { - set((state) => { - const updatedServer = { - ...state.serverList[id], - ...args, - }; + // Reset persisted grid list stores + useAlbumListDataStore.getState().actions.setItemData([]); + useAlbumArtistListDataStore.getState().actions.setItemData([]); + } + }); + }, + updateServer: (id: string, args: Partial) => { + set((state) => { + const updatedServer = { + ...state.serverList[id], + ...args, + }; - state.serverList[id] = updatedServer as ServerListItem; - state.currentServer = updatedServer as ServerListItem; - }); + state.serverList[id] = updatedServer as ServerListItem; + }); + }, }, - }, - currentServer: null, - deviceId: nanoid(), - serverList: {}, - })), + currentServerId: null, + deviceId: nanoid(), + serverList: {}, + })), + ), { name: 'store_authentication' }, ), { @@ -89,18 +88,40 @@ export const useAuthStore = createWithEqualityFn()( ), ); -export const useCurrentServerId = () => useAuthStore((state) => state.currentServer)?.id || ''; +export const useAuthStore = createSelectors(authStoreBase); -export const useCurrentServer = () => useAuthStore((state) => state.currentServer); +export const useCurrentServerId = () => { + return useAuthStore.use.currentServerId(); +}; -export const useServerList = () => useAuthStore((state) => state.serverList); +export const useCurrentServer = () => { + const currentServerId = useCurrentServerId(); -export const useAuthStoreActions = () => useAuthStore((state) => state.actions); - -export const getServerById = (id?: string) => { - if (!id) { + if (!currentServerId) { return null; } - return useAuthStore.getState().actions.getServer(id); + const servers = useAuthStore.use.serverList(); + const server = servers[currentServerId]; + + if (!server) { + return null; + } + + return server; +}; + +export const useServerList = () => useAuthStore.use.serverList(); + +export const useAuthStoreActions = () => useAuthStore.use.actions(); + +export const useServerById = (id: string) => { + const servers = useAuthStore.use.serverList(); + const server = servers[id]; + + if (!server) { + return null; + } + + return server; }; diff --git a/src/renderer/store/utils.ts b/src/renderer/store/utils.ts index 2521ea4dc..f0dafe101 100644 --- a/src/renderer/store/utils.ts +++ b/src/renderer/store/utils.ts @@ -1,4 +1,5 @@ import mergeWith from 'lodash/mergeWith'; +import { StoreApi, UseBoundStore } from 'zustand'; /** * A custom deep merger that will replace all 'columns' items with the persistent @@ -17,3 +18,17 @@ export const mergeOverridingColumns = (persistedState: unknown, currentState: return undefined; }); }; + +type WithSelectors = S extends { getState: () => infer T } + ? S & { use: { [K in keyof T]: () => T[K] } } + : never; + +export function createSelectors>>(_store: S) { + const store = _store as WithSelectors; + store.use = {}; + for (const k of Object.keys(store.getState())) { + (store.use as any)[k] = () => store((s) => s[k as keyof typeof s]); + } + + return store; +} diff --git a/src/renderer/utils/set-transcoded-queue-data.ts b/src/renderer/utils/set-transcoded-queue-data.ts index c1f8657d8..d5a3b87be 100644 --- a/src/renderer/utils/set-transcoded-queue-data.ts +++ b/src/renderer/utils/set-transcoded-queue-data.ts @@ -1,7 +1,7 @@ import isElectron from 'is-electron'; import { api } from '/@/renderer/api'; -import { getServerById, useSettingsStore } from '/@/renderer/store'; +import { useServerById, useSettingsStore } from '/@/renderer/store'; import { PlayerData, QueueSong, QueueSong } from '/@/shared/types/domain/player-domain-types'; const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; @@ -11,7 +11,7 @@ const modifyUrl = (song: QueueSong): string => { if (transcode.enabled) { const streamUrl = api.controller.getTranscodingUrl({ apiClientProps: { - server: getServerById(song.serverId), + server: useServerById(song.serverId), }, query: { base: song.streamUrl, diff --git a/src/shared/api/navidrome/navidrome-normalize.ts b/src/shared/api/navidrome/navidrome-normalize.ts index 4cbec35a2..ce82597ff 100644 --- a/src/shared/api/navidrome/navidrome-normalize.ts +++ b/src/shared/api/navidrome/navidrome-normalize.ts @@ -384,7 +384,7 @@ const normalizeUser = (item: z.infer): User => { id: item.id, isAdmin: item.isAdmin, lastLoginAt: item.lastLoginAt, - name: item.userName, + username: item.userName, updatedAt: item.updatedAt, }; }; diff --git a/src/shared/api/subsonic/subsonic-controller.ts b/src/shared/api/subsonic/subsonic-controller.ts index 5472eb626..cf5eabcd9 100644 --- a/src/shared/api/subsonic/subsonic-controller.ts +++ b/src/shared/api/subsonic/subsonic-controller.ts @@ -1,14 +1,25 @@ -import createClient, { Client, Middleware } from 'openapi-fetch'; +import md5 from 'md5'; +import createClient, { Middleware } from 'openapi-fetch'; import qs from 'qs'; -import { paths } from './subsonic-schema'; +import { components, paths } from './subsonic-schema'; import i18n from '/@/i18n/i18n'; import { normalize } from '/@/shared/api/subsonic/subsonic-normalize'; -import { API_CLIENT_NAME, ApiController } from '/@/shared/types/adapter/api-controller-types'; -import { ApiControllerError } from '/@/shared/types/adapter/api-controller-types'; +import { helpers } from '/@/shared/api/utils'; +import { + API_CLIENT_NAME, + ApiController, + ApiControllerError, +} from '/@/shared/types/adapter/api-controller-types'; +import { AlbumListSortOptions } from '/@/shared/types/domain/album-domain-types'; +import { Artist } from '/@/shared/types/domain/artist-domain-types'; +import { Genre } from '/@/shared/types/domain/genre-domain-types'; import { ServerListItem, ServerType } from '/@/shared/types/domain/server-domain-types'; -import { LibraryItem } from '/@/shared/types/domain/shared-domain-types'; +import { LibraryItem, ListSortOrder } from '/@/shared/types/domain/shared-domain-types'; +import { randomString } from '/@/shared/utils/random-string'; + +export type SubsonicApiClient = ReturnType; function deserializeCredential(credential: string): Record { return JSON.parse(credential); @@ -27,11 +38,23 @@ function serializeCredential(username: string, credential: Record({ - querySerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }), -}); +export const createApiClient = ( + server: ServerListItem, + middleware?: ((server: ServerListItem) => Middleware)[], +) => { + const client = createClient({ + baseUrl: server.url, + querySerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }), + }); -export const middleware: (server: ServerListItem) => Middleware = (server: ServerListItem) => ({ + if (middleware) { + client.use(...middleware.map((m) => m(server))); + } + + return client; +}; + +const authMiddleware: (server: ServerListItem) => Middleware = (server: ServerListItem) => ({ onRequest: async ({ params }) => { const credential = deserializeCredential(server.credential); @@ -47,9 +70,7 @@ export const middleware: (server: ServerListItem) => Middleware = (server: Serve }, }); -const client = createClient({ - querySerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }), -}); +export const middleware = [authMiddleware]; type ErrorResponseArgs = { code?: number; @@ -90,20 +111,18 @@ function getSubsonicErrorMessage(subsonicErrorCode: number): string { } function subsonicErrorResponse( - subsonicErrorCode: number, + response: components['schemas']['SubsonicError'], customMessage?: string, ): [ApiControllerError, null] { - const httpStatus = toHttpErrorCode(subsonicErrorCode); - const message = customMessage || getSubsonicErrorMessage(subsonicErrorCode); + const errorCode = response.code; + const errorMessage = response.message; + + const httpStatus = toHttpErrorCode(errorCode); + const message = customMessage || errorMessage || getSubsonicErrorMessage(errorCode); return [{ code: httpStatus, message }, null]; } -/** - * Maps Subsonic error codes to appropriate HTTP status codes - * @param subsonicErrorCode - The Subsonic error code - * @returns The corresponding HTTP status code - */ function toHttpErrorCode(subsonicErrorCode: number): number { switch (subsonicErrorCode) { case 0: @@ -129,80 +148,1689 @@ function toHttpErrorCode(subsonicErrorCode: number): number { } } -const baseController: ApiController = { - _utility: { - getImageUrl: ( - args: { id: string; size?: number; type: LibraryItem }, - server: ServerListItem, - ) => { - return `${server.url}/rest/getCoverArt?id=${args.id}&size=${args.size || 300}`; - }, - getStreamUrl: ( - args: { bitRate?: number; format?: string; id: string }, - server: ServerListItem, - ) => { - return `${server.url}/rest/stream?id=${args.id}&format=${args.format || 'mp3'}&maxBitRate=${args.bitRate || 320}`; - }, - }, - album: { - getDetail: async (request, server, options) => { - const { data, error } = await client.GET('/rest/getAlbum', { - baseUrl: server.url, - params: { query: { id: request.query.id } }, - ...options, - }); +export const authenticate = async ({ body, url }) => { + /* + * We will attempt to authenticate in three ways: + * 1. Username and token (md5(password + salt)) + * 2. Username and plaintext password + * 3. API key https://opensubsonic.netlify.app/docs/extensions/apikeyauth/ + */ - if (error) { - return errorResponse({ code: 500, message: error }); - } + const authUrl = `${url}/rest/getUser`; - if (!data['subsonic-response']) { - return errorResponse({ code: 404, message: 'No album found' }); - } + async function tokenAuth(username: string, credential: string) { + const salt = randomString(12); + const token = md5(credential + salt); - if (data['subsonic-response'].status !== 'ok') { - const errorCode = data['subsonic-response'].error.code; - const errorMessage = data['subsonic-response'].error.message; - return subsonicErrorResponse(errorCode, errorMessage); - } + const authQuery = { + s: salt, + t: token, + u: username, + }; - return [null, normalize.album(data['subsonic-response'].album, server)]; + const query = { + c: API_CLIENT_NAME, + f: 'json', + username, + v: '1.16.1', + ...authQuery, + }; + + const parsedQuery = qs.stringify(query, { arrayFormat: 'repeat' }); + + const result = await fetch(`${authUrl}?${parsedQuery}`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + }); + + return { authQuery, result }; + } + + async function plaintextAuth(username: string, credential: string) { + const authQuery = { + p: credential, + u: username, + }; + + const query = { + c: API_CLIENT_NAME, + f: 'json', + username, + v: '1.16.1', + ...authQuery, + }; + + const parsedQuery = qs.stringify(query, { arrayFormat: 'repeat' }); + + const result = await fetch(`${authUrl}?${parsedQuery}`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + }); + + return { authQuery, result }; + } + + async function apiKeyAuth(username: string, credential: string) { + const authQuery = { + apiKey: credential, + u: username, + }; + + const query = { + c: API_CLIENT_NAME, + f: 'json', + username, + v: '1.16.1', + ...authQuery, + }; + + const parsedQuery = qs.stringify(query, { arrayFormat: 'repeat' }); + + const result = await fetch(`${authUrl}?${parsedQuery}`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + }); + + return { authQuery: { ...authQuery }, result }; + } + + let errorMessage: null | string = null; + + const authFunctions = [ + { + fn: tokenAuth, + type: 'token', }, - }, - albumArtist: { - // TODO: Implement album artist methods - }, - artist: { - // TODO: Implement artist methods - }, - favorite: { - // TODO: Implement favorite methods - }, - genre: { - // TODO: Implement genre methods - }, - musicFolder: { - // TODO: Implement music folder methods - }, - playlist: { - // TODO: Implement playlist methods - }, - server: { - authenticate: async ( - url: string, - body: { legacy?: boolean; password: string; username: string }, - ) => { - // TODO: Implement authentication logic - throw new Error('Authentication not implemented yet'); + { + fn: apiKeyAuth, + type: 'apiKey', }, - getType: () => ServerType.SUBSONIC, - }, - song: { - // TODO: Implement song methods - }, - user: { - // TODO: Implement user methods - }, + { + fn: plaintextAuth, + type: 'plaintext', + }, + ]; + + for (const authFn of authFunctions) { + const { authQuery, result } = await authFn.fn(body.username, body.password); + + const resultData = await result.json(); + + if (resultData.error) { + continue; + } + + if (resultData['subsonic-response']?.status !== 'ok') { + errorMessage = resultData['subsonic-response'].error?.message as unknown as string; + continue; + } + + const userResult = resultData['subsonic-response'].user; + + const serializedCredential = serializeCredential(body.username, authQuery, authFn.type); + + const query = { + c: API_CLIENT_NAME, + f: 'json', + v: '1.16.1', + ...authQuery, + }; + + const parsedQuery = qs.stringify(query, { arrayFormat: 'repeat' }); + + const musicFolderUrl = `${url}/rest/getMusicFolders`; + + const musicFolderResult = await fetch(`${musicFolderUrl}?${parsedQuery}`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + }); + + const musicFolderResultData = await musicFolderResult.json(); + + if (!musicFolderResultData || musicFolderResultData['subsonic-response']?.status !== 'ok') { + errorMessage = musicFolderResultData['subsonic-response'].error + ?.message as unknown as string; + continue; + } + + const musicFolders = ( + musicFolderResultData['subsonic-response'].musicFolders.musicFolder || [] + ).map((folder) => folder.id.toString()); + + const user = { + credential: serializedCredential, + permissions: { + 'jukebox.manage': userResult.jukeboxRole, + 'media.download': userResult.downloadRole, + 'media.folder': musicFolders, + 'media.share': userResult.shareRole, + 'media.stream': userResult.streamRole, + 'media.upload': userResult.uploadRole, + 'playlist.create': userResult.playlistRole, + 'playlist.delete': userResult.playlistRole, + 'playlist.edit': userResult.playlistRole, + 'server.admin': userResult.adminRole, + 'user.edit': userResult.settingsRole, + }, + username: userResult.username, + }; + + return [null, user]; + } + + return errorResponse({ message: errorMessage || undefined }); }; -export const controller = baseController; +export const controller = (client: SubsonicApiClient, server: ServerListItem): ApiController => { + return { + _utility: { + getImageUrl: ( + args: { id: string; size?: number; type: LibraryItem }, + server: ServerListItem, + ) => { + return `${server.url}/rest/getCoverArt?id=${args.id}&size=${args.size || 300}`; + }, + getStreamUrl: ( + args: { bitRate?: number; format?: string; id: string }, + server: ServerListItem, + ) => { + return `${server.url}/rest/stream?id=${args.id}&format=${args.format || 'mp3'}&maxBitRate=${args.bitRate || 320}`; + }, + }, + album: { + getDetail: async (request, options) => { + const { data, error } = await client.GET('/rest/getAlbum', { + params: { query: { id: request.query.id } }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No album found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [null, normalize.album(data['subsonic-response'].album, server)]; + }, + getInfo: async (request, options) => { + const { data, error } = await client.GET('/rest/getAlbumInfo2', { + params: { query: { id: request.query.id } }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No album info found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const albumInfo = data['subsonic-response'].albumInfo; + return [ + null, + { + imageUrl: albumInfo?.largeImageUrl || null, + notes: albumInfo?.notes || null, + }, + ]; + }, + getList: async (request, options) => { + let reverseResult: boolean = false; + let offset: number = request.query.offset; + const fromYear: number | undefined = undefined; + const toYear: number | undefined = undefined; + + const [err, totalRecordCount] = await controller(client, server).album + .getListCount!(request, options); + + if (err) { + return errorResponse({ code: 500, message: err.message }); + } + + if (request.query.searchTerm) { + if (request.query.limit === -1) { + const fetcherFn = async (page: number, limit: number) => { + const { data, error } = await client.GET('/rest/search3', { + params: { + query: { + albumCount: limit, + albumOffset: page * limit, + artistCount: 0, + artistOffset: 0, + query: request.query.searchTerm || '', + songCount: 0, + songOffset: 0, + }, + }, + ...options, + }); + + if (error) { + throw new Error(error); + } + + if (!data['subsonic-response']) { + throw new Error('No response from server'); + } + + if (data['subsonic-response'].status !== 'ok') { + throw new Error(data['subsonic-response'].error?.message); + } + + return data['subsonic-response'].searchResult3?.album || []; + }; + + const results = await helpers.fetchAllRecords({ + fetcher: fetcherFn, + fetchLimit: 500, + }); + + const items = results.map((album) => normalize.album(album, server)); + + const sorted = helpers.sortBy.album( + items, + request.query.sortBy, + request.query.sortOrder, + ); + + const paginatedResults = helpers.paginate( + sorted, + request.query.offset, + request.query.limit, + ); + + return [null, paginatedResults]; + } + + if (request.query.sortOrder === ListSortOrder.DESC) { + offset = totalRecordCount - offset - request.query.limit; + reverseResult = true; + } + + const { data, error } = await client.GET('/rest/search3', { + params: { + query: { + albumCount: request.query.limit, + albumOffset: offset, + artistCount: 0, + artistOffset: 0, + query: request.query.searchTerm, + songCount: 0, + songOffset: 0, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No albums found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const result = data['subsonic-response'].searchResult3?.album || []; + + let items = result.map((album) => normalize.album(album, server)); + + items = helpers.sortBy.album( + items, + request.query.sortBy, + request.query.sortOrder, + ); + + if (reverseResult) { + items.reverse(); + } + + return [ + null, + { + items, + offset: request.query.offset, + totalRecordCount, + }, + ]; + } + + switch (request.query.sortBy) { + case AlbumListSortOptions.ALBUM_ARTIST: + // Default is ascending + if (request.query.sortOrder === ListSortOrder.DESC) { + offset = totalRecordCount - request.query.offset - request.query.limit; + reverseResult = true; + } + break; + case AlbumListSortOptions.DATE_ADDED: + // Default is descending + if (request.query.sortOrder === ListSortOrder.DESC) { + offset = totalRecordCount - request.query.offset - request.query.limit; + reverseResult = true; + } + break; + case AlbumListSortOptions.DATE_PLAYED: + // Default is descending + if (request.query.sortOrder === ListSortOrder.ASC) { + offset = totalRecordCount - request.query.offset - request.query.limit; + reverseResult = true; + } + break; + case AlbumListSortOptions.IS_FAVORITE: + // Default is ascending + if (request.query.sortOrder === ListSortOrder.DESC) { + offset = totalRecordCount - request.query.offset - request.query.limit; + reverseResult = true; + } + break; + case AlbumListSortOptions.NAME: + // Default is ascending + if (request.query.sortOrder === ListSortOrder.DESC) { + offset = totalRecordCount - request.query.offset - request.query.limit; + reverseResult = true; + } + break; + case AlbumListSortOptions.PLAY_COUNT: + // Default is descending + if (request.query.sortOrder === ListSortOrder.ASC) { + offset = totalRecordCount - request.query.offset - request.query.limit; + reverseResult = true; + } + break; + case AlbumListSortOptions.RANDOM: + break; + case AlbumListSortOptions.RATING: + // Default is ascending + if (request.query.sortOrder === ListSortOrder.DESC) { + offset = totalRecordCount - request.query.offset - request.query.limit; + reverseResult = true; + } + break; + case AlbumListSortOptions.YEAR: + break; + default: + break; + } + + const { data, error } = await client.GET('/rest/getAlbumList2', { + params: { + query: { + fromYear, + musicFolderId: request.query.musicFolderId, + offset: request.query.offset, + size: request.query.limit, + toYear, + type: normalize._sort.album(request.query.sortBy), + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No albums found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const isPartialResult = offset < 0; + + const skip = isPartialResult + ? Math.max(offset + Number(request.query.limit), 0) + : 0; + + const items = (data['subsonic-response'].albumList2?.album || []) + .slice(skip) + .map((album) => normalize.album(album, server)); + + if (reverseResult) { + items.reverse(); + } + + return [ + null, + { + items, + offset: request.query.offset, + totalRecordCount, + }, + ]; + }, + getListCount: async (request, options) => { + async function getPageItemCount(page: number, limit: number): Promise { + const { data, error } = await client.GET('/rest/getAlbumList2', { + params: { + query: { + musicFolderId: request.query.musicFolderId, + offset: page * limit, + size: limit, + type: normalize._sort.album(request.query.sortBy), + }, + }, + ...options, + }); + + if (error) { + throw new Error(error); + } + + if (!data['subsonic-response']) { + throw new Error('No response from server'); + } + + if (data['subsonic-response'].status !== 'ok') { + throw new Error(data['subsonic-response'].error?.message); + } + + return data['subsonic-response'].albumList2?.album?.length || 0; + } + + async function getSearchPageItemCount( + page: number, + limit: number, + ): Promise { + const { data, error } = await client.GET('/rest/search3', { + params: { + query: { + albumCount: limit, + albumOffset: page * limit, + artistCount: 0, + artistOffset: 0, + query: request.query.searchTerm || '', + songCount: 0, + songOffset: 0, + }, + }, + ...options, + }); + + if (error) { + throw new Error(error); + } + + if (!data['subsonic-response']) { + throw new Error('No response from server'); + } + + if (data['subsonic-response'].status !== 'ok') { + throw new Error(data['subsonic-response'].error?.message); + } + + return data['subsonic-response'].searchResult3?.album?.length || 0; + } + + const pageItemCountFn = request.query.searchTerm + ? getSearchPageItemCount + : getPageItemCount; + + try { + const totalRecordCount = await helpers.fetchTotalRecordCount({ + fetcher: pageItemCountFn, + fetchLimit: 500, + }); + + return [null, totalRecordCount]; + } catch (error) { + return errorResponse({ code: 500, message: error as string }); + } + }, + }, + albumArtist: { + getDetail: async (request, options) => { + const { data, error } = await client.GET('/rest/getArtist', { + params: { query: { id: request.query.id } }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No artist found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const artist = data['subsonic-response'].artist; + + return [ + null, + { + ...normalize.albumArtist(artist, server), + albums: artist.album?.map((album) => normalize.album(album, server)) || [], + similarArtists: [], + }, + ]; + }, + getList: async (request, options) => { + const { data, error } = await client.GET('/rest/getArtists', { + params: { + query: { + musicFolderId: request.query.musicFolderId, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No artists found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + let artists: Artist[] = (data['subsonic-response'].artists?.index || []) + .flatMap((index) => index) + .map((artist) => normalize.albumArtist(artist, server)); + + if (request.query.searchTerm) { + const searchTerm = request.query.searchTerm; + artists = helpers.search(artists, searchTerm, ['name']); + } + + const sorted = helpers.sortBy.artist( + artists, + request.query.sortBy, + request.query.sortOrder, + ); + + const paginatedResults = helpers.paginate( + sorted, + request.query.offset, + request.query.limit, + ); + + return [null, paginatedResults]; + }, + getListCount: async (request, options) => { + if (request.totalRecordCount) { + return [null, request.totalRecordCount]; + } + + const { data, error } = await client.GET('/rest/getArtists', { + params: { + query: { + musicFolderId: request.query.musicFolderId, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No artists found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + let artists: Artist[] = (data['subsonic-response'].artists?.index || []) + .flatMap((index) => index) + .map((artist) => normalize.albumArtist(artist, server)); + + if (request.query.searchTerm) { + const searchTerm = request.query.searchTerm; + artists = helpers.search(artists, searchTerm, ['name']); + } + + return [null, artists.length]; + }, + }, + artist: { + getList: async (request, options) => { + const { data, error } = await client.GET('/rest/getArtists', { + params: { + query: { + musicFolderId: request.query.musicFolderId, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No artists found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + let artists = (data['subsonic-response'].artists?.index || []) + .flatMap((index) => index) + .map((artist) => normalize.albumArtist(artist, server)); + + if (request.query.searchTerm) { + const searchTerm = request.query.searchTerm; + artists = helpers.search(artists, searchTerm, ['name']); + } + + const sorted = helpers.sortBy.artist( + artists, + request.query.sortBy, + request.query.sortOrder, + ); + + const paginatedResults = helpers.paginate( + sorted, + request.query.offset, + request.query.limit, + ); + + return [null, paginatedResults]; + }, + getListCount: async (request, options) => { + const { data, error } = await client.GET('/rest/getArtists', { + params: { query: { musicFolderId: request.query.musicFolderId } }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No artists found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + let artists: Artist[] = (data['subsonic-response'].artists?.index || []) + .flatMap((index) => index) + .map((artist) => normalize.albumArtist(artist, server)); + + if (request.query.searchTerm) { + const searchTerm = request.query.searchTerm; + artists = helpers.search(artists, searchTerm, ['name']); + } + + return [null, artists.length]; + }, + }, + genre: { + getList: async (request, options) => { + const { data, error } = await client.GET('/rest/getGenres', { + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No genres found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const results = data['subsonic-response'].genres?.genre || []; + let genres: Genre[] = results.map((genre) => normalize.genre(genre, server)); + + if (request.query.searchTerm) { + const searchTerm = request.query.searchTerm; + genres = helpers.search(genres, searchTerm, ['name']); + } + + const sorted = helpers.sortBy.genre( + genres, + request.query.sortBy, + request.query.sortOrder, + ); + + const paginatedResults = helpers.paginate( + sorted, + request.query.offset, + request.query.limit, + ); + + return [null, paginatedResults]; + }, + }, + metadata: { + addFavorite: async (request, options) => { + const { data, error } = await client.GET('/rest/star', { + params: { + query: { + albumId: + request.query.type === LibraryItem.ALBUM + ? request.query.id + : undefined, + artistId: + request.query.type === LibraryItem.ALBUM_ARTIST + ? request.query.id + : undefined, + id: + request.query.type === LibraryItem.SONG + ? request.query.id + : undefined, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'Failed to add favorite' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [null, null]; + }, + removeFavorite: async (request, options) => { + const { data, error } = await client.GET('/rest/unstar', { + params: { + query: { + albumId: + request.query.type === LibraryItem.ALBUM + ? request.query.id + : undefined, + artistId: + request.query.type === LibraryItem.ALBUM_ARTIST + ? request.query.id + : undefined, + id: + request.query.type === LibraryItem.SONG + ? request.query.id + : undefined, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'Failed to remove favorite' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [null, null]; + }, + setRating: async (request, options) => { + const ids = request.query.id; + + for (const id of ids) { + const { data, error } = await client.GET('/rest/setRating', { + params: { + query: { + id: id, + rating: request.query.rating, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'Failed to set rating' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + } + + return [null, null]; + }, + }, + musicFolder: { + getList: async (_request, options) => { + const { data, error } = await client.GET('/rest/getMusicFolders', { + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No music folders found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const folders = data['subsonic-response'].musicFolders?.musicFolder || []; + + return [ + null, + { + items: folders.map((folder) => ({ + id: folder.id.toString(), + name: folder.name || '', + })), + offset: 0, + totalRecordCount: folders.length, + }, + ]; + }, + }, + playlist: { + addTo: async (request, options) => { + const { data, error } = await client.GET('/rest/updatePlaylist', { + params: { + query: { + playlistId: request.query.id, + songIdToAdd: request.body.songId, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'Failed to add to playlist' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [null, null]; + }, + create: async (request, options) => { + const { data, error } = await client.GET('/rest/createPlaylist', { + params: { + query: { + name: request.body.name, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'Failed to create playlist' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [ + null, + { + id: data['subsonic-response'].playlist?.id?.toString() || '', + name: request.body.name, + }, + ]; + }, + delete: async (request, options) => { + const { data, error } = await client.GET('/rest/deletePlaylist', { + params: { + query: { + id: request.query.id, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'Failed to delete playlist' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [null, null]; + }, + getDetail: async (request, options) => { + const { data, error } = await client.GET('/rest/getPlaylist', { + params: { + query: { + id: request.query.id, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No playlist found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [null, normalize.playlist(data['subsonic-response'].playlist, server)]; + }, + getList: async (request, options) => { + const { data, error } = await client.GET('/rest/getPlaylists', { + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No playlists found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const results = data['subsonic-response'].playlists?.playlist || []; + + const items = results.map((playlist) => normalize.playlist(playlist, server)); + + const sorted = helpers.sortBy.playlist( + items, + request.query.sortBy, + request.query.sortOrder, + ); + + const paginatedResults = helpers.paginate( + sorted, + request.query.offset, + request.query.limit, + ); + + return [null, paginatedResults]; + }, + getListCount: async (request, options) => { + if (request.totalRecordCount) { + return [null, request.totalRecordCount]; + } + + const { data, error } = await client.GET('/rest/getPlaylists', { + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No playlists found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const playlists = data['subsonic-response'].playlists?.playlist || []; + + return [null, playlists.length]; + }, + getSongList: async (request, options) => { + const { data, error } = await client.GET('/rest/getPlaylist', { + params: { + query: { + id: request.query.id, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No playlist found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const songs = data['subsonic-response'].playlist?.entry || []; + + const items = songs.map((song) => normalize.song(song, server)); + + const sorted = helpers.sortBy.song( + items, + request.query.sortBy, + request.query.sortOrder, + ); + + const paginatedResults = helpers.paginate( + sorted, + request.query.offset, + request.query.limit, + ); + + return [null, paginatedResults]; + }, + removeFrom: async (request, options) => { + const { data, error } = await client.GET('/rest/updatePlaylist', { + params: { + query: { + playlistId: request.query.id, + songIndexToRemove: request.query.songId.map((id) => parseInt(id)), + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'Failed to remove from playlist' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [null, null]; + }, + update: async (request, options) => { + const { data, error } = await client.GET('/rest/updatePlaylist', { + params: { + query: { + comment: request.body.comment, + name: request.body.name, + playlistId: request.query.id, + public: request.body.public, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'Failed to update playlist' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [null, null]; + }, + }, + server: { + getServerInfo: async (_request, options) => { + const { data, error } = await client.GET('/rest/ping', { + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'Failed to get server info' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [ + null, + { + features: {}, + id: server?.id, + version: data['subsonic-response'].version || 'unknown', + }, + ]; + }, + getTranscodingUrl: async (request) => { + let url = request.query.base; + if (request.query.format) { + url += `&format=${request.query.format}`; + } + if (request.query.bitrate !== undefined) { + url += `&maxBitRate=${request.query.bitrate}`; + } + return [null, url]; + }, + getType: () => ServerType.SUBSONIC, + scrobble: async (request, options) => { + const { data, error } = await client.GET('/rest/scrobble', { + params: { + query: { + id: request.query.id, + submission: request.query.submission, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'Failed to scrobble' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [null, null]; + }, + search: async (request, options) => { + const { data, error } = await client.GET('/rest/search3', { + params: { + query: { + albumCount: request.query.albumLimit, + albumOffset: request.query.albumStartIndex, + artistCount: request.query.albumArtistLimit, + artistOffset: request.query.albumArtistStartIndex, + query: request.query.query || '', + songCount: request.query.songLimit, + songOffset: request.query.songStartIndex, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No search results found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const searchResult = data['subsonic-response'].searchResult3; + return [ + null, + { + albumArtists: (searchResult?.artist || []).map((artist) => + normalize.albumArtist(artist, server), + ), + albums: (searchResult?.album || []).map((album) => + normalize.album(album, server), + ), + songs: (searchResult?.song || []).map((song) => + normalize.song(song, server), + ), + }, + ]; + }, + }, + song: { + getDetail: async (request, options) => { + const { data, error } = await client.GET('/rest/getSong', { + params: { + query: { + id: request.query.id, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No song found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [null, normalize.song(data['subsonic-response'].song, server)]; + }, + getList: async (request, options) => { + const { data, error } = await client.GET('/rest/search3', { + params: { + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: request.query.searchTerm || '', + songCount: request.query.limit, + songOffset: request.query.offset, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No songs found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const songs = data['subsonic-response'].searchResult3?.song || []; + return [ + null, + { + items: songs.map((song) => normalize.song(song, server)), + offset: request.query.offset, + totalRecordCount: null, + }, + ]; + }, + getListCount: async (request, options) => { + const sanitizedQuery = { + folderId: request.query.musicFolderId, + genreId: request.query.genreIds, + searchTerm: request.query.searchTerm, + }; + + const getPageItemCount = async (page: number, limit: number): Promise => { + const { data, error } = await client.GET('/rest/search3', { + params: { + query: { + musicFolderId: sanitizedQuery.folderId, + query: sanitizedQuery.searchTerm || '', + songCount: limit, + songOffset: page * limit, + }, + }, + ...options, + }); + + if (error) { + throw new Error(error); + } + + if (!data['subsonic-response']) { + throw new Error('No songs found'); + } + + if (data['subsonic-response'].status !== 'ok') { + throw new Error((data['subsonic-response'] as any).error); + } + + const songs = data['subsonic-response'].searchResult3?.song || []; + return songs.length; + }; + + const getPageItemCountWithGenre = async ( + page: number, + limit: number, + ): Promise => { + const { data, error } = await client.GET('/rest/getSongsByGenre', { + params: { + query: { + genre: sanitizedQuery.genreId?.[0] || '', + musicFolderId: sanitizedQuery.folderId, + songCount: limit, + songOffset: page * limit, + }, + }, + ...options, + }); + + if (error) { + throw new Error(error); + } + + if (!data['subsonic-response']) { + throw new Error('No songs found'); + } + + if (data['subsonic-response'].status !== 'ok') { + throw new Error((data['subsonic-response'] as any).error); + } + + const songs = data['subsonic-response'].songsByGenre?.song || []; + return songs.length; + }; + + try { + const fetcherFn = sanitizedQuery.genreId + ? getPageItemCountWithGenre + : getPageItemCount; + + const totalRecordCount = await helpers.getListCount( + { + expiration: 1440, + query: sanitizedQuery, + serverId: server.id, + type: LibraryItem.SONG, + }, + fetcherFn, + ); + + return [null, totalRecordCount as number]; + } catch (err) { + return [{ code: 500, message: err as string }, null]; + } + }, + getRandomList: async (request, options) => { + const { data, error } = await client.GET('/rest/getRandomSongs', { + params: { + query: { + fromYear: request.query.minYear, + genre: request.query.genre, + musicFolderId: request.query.musicFolderId, + size: request.query.limit, + toYear: request.query.maxYear, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No random songs found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const songs = data['subsonic-response'].randomSongs?.song || []; + return [ + null, + { + items: songs.map((song) => normalize.song(song, server)), + offset: 0, + totalRecordCount: songs.length, + }, + ]; + }, + getSimilar: async (request, options) => { + const { data, error } = await client.GET('/rest/getSimilarSongs', { + params: { + query: { + count: request.query.count, + id: request.query.songId, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No similar songs found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const songs = data['subsonic-response'].similarSongs?.song || []; + return [null, songs.map((song) => normalize.song(song, server))]; + }, + getStructuredLyrics: async (request, options) => { + const { data, error } = await client.GET('/rest/getLyricsBySongId', { + params: { + query: { + id: request.query.songId, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data || !data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No structured lyrics found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const lyrics = data['subsonic-response'].lyricsList?.structuredLyrics; + + if (!lyrics) { + return [null, []]; + } + + return [ + null, + lyrics.map((lyric) => { + const baseLyric = { + artist: lyric.displayArtist || '', + lang: lyric.lang, + name: lyric.displayTitle || '', + remote: false, + source: server?.name || 'music server', + }; + + if (lyric.synced) { + return { + ...baseLyric, + lyrics: lyric.line.map((line) => [line.start!, line.value]), + synced: true, + }; + } + return { + ...baseLyric, + lyrics: lyric.line.map((line) => [line.value]).join('\n'), + synced: false, + }; + }), + ]; + }, + getTopList: async (request, options) => { + const { data, error } = await client.GET('/rest/getTopSongs', { + params: { + query: { + count: request.query.limit, + id: request.query.artistId, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No top songs found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const songs = data['subsonic-response'].topSongs?.song || []; + return [ + null, + { + items: songs.map((song) => normalize.song(song, server)), + offset: 0, + totalRecordCount: songs.length, + }, + ]; + }, + }, + user: { + getList: async (request, options) => { + const { data, error } = await client.GET('/rest/getUsers', { + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No users found' }); + } + + const results = + ((data['subsonic-response'] as any).users as components['schemas']['Users']) + .user || []; + + let items = results.map((user) => normalize.user(user, server)); + + if (request.query.searchTerm) { + items = helpers.search(items, request.query.searchTerm, ['username']); + } + + const sorted = helpers.sortBy.user( + items, + request.query.sortBy, + request.query.sortOrder, + ); + + const paginatedResults = helpers.paginate( + sorted, + request.query.offset, + request.query.limit, + ); + + return [null, paginatedResults]; + }, + getListCount: async (request, options) => { + if (request.totalRecordCount) { + return [null, request.totalRecordCount]; + } + + const { data, error } = await client.GET('/rest/getUsers', { + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No users found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + return [null, data['subsonic-response'].users?.user?.length || 0]; + }, + shareItem: async (request, options) => { + const { data, error } = await client.GET('/rest/createShare', { + params: { + query: { + description: request.body.description, + expires: request.body.expires, + id: request.body.resourceIds, + resourceType: request.body.resourceType, + }, + }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'Failed to create share' }); + } + + if (data['subsonic-response'].status !== 'ok') { + return subsonicErrorResponse(data['subsonic-response'].error); + } + + const share = data['subsonic-response'].shares?.share?.[0]; + + if (!share) { + return errorResponse({ code: 404, message: 'Failed to create share' }); + } + + return [null, { id: share.id, url: share.url }]; + }, + }, + }; +}; diff --git a/src/shared/api/subsonic/subsonic-normalize.ts b/src/shared/api/subsonic/subsonic-normalize.ts index c7fdb3648..961425ca4 100644 --- a/src/shared/api/subsonic/subsonic-normalize.ts +++ b/src/shared/api/subsonic/subsonic-normalize.ts @@ -1,18 +1,49 @@ -import { components } from './subsonic-schema.d'; +import { components, paths } from './subsonic-schema.d'; import { ssType } from '/@/shared/api/subsonic/subsonic-types'; -import { Album } from '/@/shared/types/domain/album-domain-types'; +import { Album, AlbumListSortOptions } from '/@/shared/types/domain/album-domain-types'; import { Artist, RelatedArtist } from '/@/shared/types/domain/artist-domain-types'; import { Genre, RelatedGenre } from '/@/shared/types/domain/genre-domain-types'; import { Playlist } from '/@/shared/types/domain/playlist-domain-types'; import { ServerListItem, ServerType } from '/@/shared/types/domain/server-domain-types'; import { LibraryItem } from '/@/shared/types/domain/shared-domain-types'; import { Song } from '/@/shared/types/domain/song-domain-types'; +import { User } from '/@/shared/types/domain/user-domain-types'; import { formatDate } from '/@/shared/utils/format-date'; +type AlbumSortType = paths['/rest/getAlbumList2']['get']['parameters']['query']['type']; + +const defaultSortType: AlbumSortType = 'alphabeticalByName'; + +const albumSortByMap: Record = { + [AlbumListSortOptions.ALBUM_ARTIST]: 'alphabeticalByArtist', + [AlbumListSortOptions.ARTIST]: defaultSortType, + [AlbumListSortOptions.COMMUNITY_RATING]: defaultSortType, + [AlbumListSortOptions.CRITIC_RATING]: defaultSortType, + [AlbumListSortOptions.DATE_ADDED]: 'newest', + [AlbumListSortOptions.DATE_PLAYED]: 'recent', + [AlbumListSortOptions.DURATION]: defaultSortType, + [AlbumListSortOptions.IS_FAVORITE]: defaultSortType, + [AlbumListSortOptions.NAME]: 'alphabeticalByName', + [AlbumListSortOptions.PLAY_COUNT]: 'frequent', + [AlbumListSortOptions.RANDOM]: defaultSortType, + [AlbumListSortOptions.RATING]: 'highest', + [AlbumListSortOptions.RELEASE_DATE]: defaultSortType, + [AlbumListSortOptions.TRACK_COUNT]: defaultSortType, + [AlbumListSortOptions.YEAR]: defaultSortType, +}; + export const normalize = { + _sort: { + album: (option: AlbumListSortOptions) => { + return albumSortByMap[option] || defaultSortType; + }, + }, album: ( - item: components['schemas']['AlbumID3'] | components['schemas']['AlbumID3WithSongs'], + item: + | components['schemas']['AlbumID3'] + | components['schemas']['AlbumID3WithSongs'] + | components['schemas']['Child'], server: ServerListItem, ): Album => { const imageUrl = item.coverArt ? getCoverArtUrl(item.coverArt, server) : null; @@ -24,7 +55,7 @@ export const normalize = { artistName: item.artist || null, artists: getArtistList(item.artists, item.artistId, item.artist), comment: null, - createdDate: item.created, + createdDate: item.created || null, discTitles: getDiscTitles(item), displayArtist: null, duration: getDuration(item.duration), @@ -33,12 +64,12 @@ export const normalize = { id: item.id.toString(), imagePlaceholderUrl: null, imageUrl, - isCompilation: item.isCompilation || null, + isCompilation: getIsCompilation(item), mbzId: item.musicBrainzId || null, mbzReleaseGroupId: null, missing: null, moods: getMoods(item), - name: item.name, + name: getName(item), originalReleaseDate: getOriginalReleaseDate(item), participants: {}, recordLabels: getRecordLabels(item), @@ -46,8 +77,8 @@ export const normalize = { releaseTypes: getReleaseTypes(item), releaseYear: item.year || null, size: null, - songCount: item.songCount, - sortName: item.sortName || item.name, + songCount: 'songCount' in item ? item.songCount : null, + sortName: getSortName(item), tags: {}, updatedDate: null, userFavorite: Boolean(item.starred), @@ -55,7 +86,7 @@ export const normalize = { userLastPlayedDate: item.played || null, userPlayCount: item.playCount ?? null, userRating: item.userRating || null, - version: item.version || null, + version: 'version' in item ? item.version || null : null, }; }, albumArtist: ( @@ -167,6 +198,28 @@ export const normalize = { userRating: item.userRating || null, }; }, + user: (item: components['schemas']['User'], server: ServerListItem): User => { + return { + _itemType: LibraryItem.USER, + _serverId: server.id, + _serverType: ServerType.SUBSONIC, + id: item.username, + permissions: { + 'jukebox.manage': item.jukeboxRole, + 'media.download': item.downloadRole, + 'media.folder': item.folder?.map((folder) => folder.toString()) || [], + 'media.share': item.shareRole, + 'media.stream': item.streamRole, + 'media.upload': item.uploadRole, + 'playlist.create': item.playlistRole, + 'playlist.delete': item.playlistRole, + 'playlist.edit': item.playlistRole, + 'server.admin': item.adminRole, + 'user.edit': item.settingsRole, + }, + username: item.username, + }; + }, }; function getArtistList( @@ -200,12 +253,19 @@ function getCoverArtUrl(id: string, server: ServerListItem) { } function getDiscTitles( - item: components['schemas']['AlbumID3'] | components['schemas']['AlbumID3WithSongs'], + item: + | components['schemas']['AlbumID3'] + | components['schemas']['AlbumID3WithSongs'] + | components['schemas']['Child'], ) { - return (item.discTitles || []).map((discTitle) => ({ - disc: discTitle.disc, - title: discTitle.title, - })); + if ('discTitles' in item) { + return (item.discTitles || []).map((discTitle) => ({ + disc: discTitle.disc, + title: discTitle.title, + })); + } + + return []; } function getDuration(duration?: number) { @@ -214,12 +274,16 @@ function getDuration(duration?: number) { } function getGainInfo(item: components['schemas']['Child']) { - return item.replayGain && (item.replayGain.albumGain || item.replayGain.trackGain) - ? { - album: item.replayGain.albumGain, - track: item.replayGain.trackGain, - } - : null; + if ('replayGain' in item) { + return item.replayGain && (item.replayGain.albumGain || item.replayGain.trackGain) + ? { + album: item.replayGain.albumGain, + track: item.replayGain.trackGain, + } + : null; + } + + return null; } function getGenres( @@ -228,15 +292,19 @@ function getGenres( | components['schemas']['AlbumID3WithSongs'] | components['schemas']['Child'], ): RelatedGenre[] { - if (item.genres) { - return item.genres.map((genre) => ({ + if ('genres' in item) { + return (item.genres || []).map((genre) => ({ id: genre.name, imageUrl: null, name: genre.name, })); } - if (item.genre) { + if ('genre' in item) { + if (!item.genre) { + return []; + } + return [ { id: item.genre, @@ -249,26 +317,67 @@ function getGenres( return []; } +function getIsCompilation( + item: + | components['schemas']['AlbumID3'] + | components['schemas']['AlbumID3WithSongs'] + | components['schemas']['Child'], +) { + if ('isCompilation' in item) { + return item.isCompilation || null; + } + + return null; +} + function getMoods( item: | components['schemas']['AlbumID3'] | components['schemas']['AlbumID3WithSongs'] | components['schemas']['Child'], ) { - return (item.moods || []).map((mood) => ({ - id: mood, - name: mood, - })); + if ('moods' in item) { + return (item.moods || []).map((mood) => ({ + id: mood, + name: mood, + })); + } + + return []; +} + +function getName( + item: + | components['schemas']['AlbumID3'] + | components['schemas']['AlbumID3WithSongs'] + | components['schemas']['Child'], +) { + if ('name' in item) { + return item.name || ''; + } + + if ('title' in item) { + return item.title || ''; + } + + return ''; } function getOriginalReleaseDate( - item: components['schemas']['AlbumID3'] | components['schemas']['AlbumID3WithSongs'], + item: + | components['schemas']['AlbumID3'] + | components['schemas']['AlbumID3WithSongs'] + | components['schemas']['Child'], ) { - return item.originalReleaseDate - ? formatDate.toUTCDate( - `${item.originalReleaseDate.year}-${item.originalReleaseDate.month}-${item.originalReleaseDate.day}`, - ) - : null; + if ('originalReleaseDate' in item) { + return item.originalReleaseDate + ? formatDate.toUTCDate( + `${item.originalReleaseDate.year}-${item.originalReleaseDate.month}-${item.originalReleaseDate.day}`, + ) + : null; + } + + return null; } function getParticipants(item: components['schemas']['Child']) { @@ -298,40 +407,86 @@ function getParticipants(item: components['schemas']['Child']) { } function getPeakInfo(item: components['schemas']['Child']) { - return item.replayGain && (item.replayGain.albumPeak || item.replayGain.trackPeak) - ? { - album: item.replayGain.albumPeak, - track: item.replayGain.trackPeak, - } - : null; + if ('replayGain' in item) { + return item.replayGain && (item.replayGain.albumPeak || item.replayGain.trackPeak) + ? { + album: item.replayGain.albumPeak, + track: item.replayGain.trackPeak, + } + : null; + } + + return null; } function getRecordLabels( - item: components['schemas']['AlbumID3'] | components['schemas']['AlbumID3WithSongs'], + item: + | components['schemas']['AlbumID3'] + | components['schemas']['AlbumID3WithSongs'] + | components['schemas']['Child'], ) { - return (item.recordLabels || []).map((recordLabel) => ({ - id: recordLabel.name, - name: recordLabel.name, - })); + if ('recordLabels' in item) { + return (item.recordLabels || []).map((recordLabel) => ({ + id: recordLabel.name, + name: recordLabel.name, + })); + } + + return []; } function getReleaseDate( - item: components['schemas']['AlbumID3'] | components['schemas']['AlbumID3WithSongs'], + item: + | components['schemas']['AlbumID3'] + | components['schemas']['AlbumID3WithSongs'] + | components['schemas']['Child'], ) { - return item.releaseDate - ? formatDate.toUTCDate( - `${item.releaseDate.year}-${item.releaseDate.month}-${item.releaseDate.day}`, - ) - : null; + if ('releaseDate' in item) { + return item.releaseDate + ? formatDate.toUTCDate( + `${item.releaseDate.year}-${item.releaseDate.month}-${item.releaseDate.day}`, + ) + : null; + } + + return null; } function getReleaseTypes( - item: components['schemas']['AlbumID3'] | components['schemas']['AlbumID3WithSongs'], + item: + | components['schemas']['AlbumID3'] + | components['schemas']['AlbumID3WithSongs'] + | components['schemas']['Child'], ) { - return (item.releaseTypes || []).map((releaseType) => ({ - id: releaseType, - name: releaseType, - })); + if ('releaseTypes' in item) { + return (item.releaseTypes || []).map((releaseType) => ({ + id: releaseType, + name: releaseType, + })); + } + + return []; +} + +function getSortName( + item: + | components['schemas']['AlbumID3'] + | components['schemas']['AlbumID3WithSongs'] + | components['schemas']['Child'], +) { + if ('sortName' in item) { + return item.sortName || ''; + } + + if ('name' in item) { + return item.name || ''; + } + + if ('title' in item) { + return item.title || ''; + } + + return ''; } function getStreamUrl(id: string, server: ServerListItem) { diff --git a/src/shared/api/utils.ts b/src/shared/api/utils.ts index bd24f3d12..436a1b533 100644 --- a/src/shared/api/utils.ts +++ b/src/shared/api/utils.ts @@ -1,10 +1,24 @@ import { AxiosHeaders } from 'axios'; +import dayjs from 'dayjs'; import isElectron from 'is-electron'; +import { orderBy, shuffle } from 'lodash'; +import { stringify } from 'querystring'; import semverCoerce from 'semver/functions/coerce'; import semverGte from 'semver/functions/gte'; import { z } from 'zod'; +import { Album, AlbumListSortOptions } from '/@/shared/types/domain/album-domain-types'; +import { Artist, ArtistListSortOptions } from '/@/shared/types/domain/artist-domain-types'; +import { Genre, GenreListSortOptions } from '/@/shared/types/domain/genre-domain-types'; +import { + Playlist, + PlaylistListSortOptions, + PlaylistSong, +} from '/@/shared/types/domain/playlist-domain-types'; import { ServerFeature, ServerListItem } from '/@/shared/types/domain/server-domain-types'; +import { LibraryItem, ListSortOrder } from '/@/shared/types/domain/shared-domain-types'; +import { Song, SongListSortOptions } from '/@/shared/types/domain/song-domain-types'; +import { User, UserListSortOptions } from '/@/shared/types/domain/user-domain-types'; // Since ts-rest client returns a strict response type, we need to add the headers to the body object export const resultWithHeaders = (itemSchema: ItemType) => { @@ -110,3 +124,522 @@ export const getClientType = (): string => { }; export const SEPARATOR_STRING = ' ยท '; + +export async function estimateTotalRecordCount(args: { + fetcher: (page: number, limit: number) => Promise; + limit: number; +}) { + const { fetcher, limit } = args; + + // Recursive binary search across all pages to estimate total rows + async function estimateTotalRowsRecursive( + low: number, + high: number, + limit: number, + ): Promise { + if (low > high) { + return 0; // This condition is just a safeguard and shouldn't be reached + } + + const mid = Math.floor((low + high) / 2); + const data = await fetcher(mid, limit); + + if (data < limit) { + // If the current page contains fewer than 500 items, it's close to the last page + const itemCount = (mid - 1) * limit + data; + return itemCount; + } else { + // If the current page is full, search in the higher half + return estimateTotalRowsRecursive(mid + 1, high, limit); + } + } + + // Function to estimate total rows with limited page size + async function estimateTotalRows(): Promise { + let low = 1; + let high = 2; + + // Step 1: Exponentially grow the number of pages to get an upper bound for the total pages + + while (true) { + const data = await fetcher(high, limit); + + if (data < limit) { + // If we encounter the last page, break out of the loop + break; + } + + // Double the upper bound for the number of pages + low = high; + high *= 2; + } + + // Step 2: Perform binary search across all pages to find the exact number of rows + return estimateTotalRowsRecursive(low, high, limit); + } + + return estimateTotalRows(); +} + +export async function exactTotalRecordCount(args: { + fetcher: (page: number, limit: number) => Promise; + limit: number; + startPage: number; +}) { + const { fetcher, limit, startPage } = args; + + // Add early return for page 1 with no results + if (startPage === 1) { + const firstPageCount = await fetcher(1, limit); + if (firstPageCount === 0) { + return 0; + } + } + + const fetchCountRecursive = async ( + page: number, + limit: number, + reverse: boolean, + totalRecordCount: number, + previousPageRecordCount?: number, + ): Promise => { + // Add guard against negative page numbers + if (page < 1) { + return totalRecordCount; + } + + const currentPageRecordCount = await fetcher(page, limit); + + if (currentPageRecordCount !== limit && currentPageRecordCount !== 0) { + totalRecordCount += currentPageRecordCount; + return totalRecordCount; + } + + // Handle the case when the last page is equal to the limit and is ascending + if ( + !reverse && + currentPageRecordCount !== limit && + currentPageRecordCount === 0 && + currentPageRecordCount === previousPageRecordCount + ) { + return totalRecordCount; + } + + if (reverse) { + totalRecordCount -= limit; + return fetchCountRecursive( + page - 1, + limit, + true, + totalRecordCount, + currentPageRecordCount, + ); + } else { + totalRecordCount += currentPageRecordCount; + return fetchCountRecursive( + page + 1, + limit, + false, + totalRecordCount, + currentPageRecordCount, + ); + } + }; + + const estimatedStartRecordCount = startPage * limit; + const startPageRecordCount = await fetcher(startPage, limit); + const isLastPage = startPageRecordCount < limit && startPageRecordCount !== 0; + + if (isLastPage) { + if (estimatedStartRecordCount < limit) { + return estimatedStartRecordCount + startPageRecordCount; + } + + return estimatedStartRecordCount - limit + startPageRecordCount; + } + + const shouldReverse = startPageRecordCount < limit; + + const count = await fetchCountRecursive( + startPage, + limit, + shouldReverse, + estimatedStartRecordCount, + ); + return count; +} + +export async function fetchAllRecords(args: { + fetcher: (page: number, limit: number) => Promise; + fetchLimit?: number; + items?: T[]; + page?: number; +}) { + const limit = args.fetchLimit || 500; + const page = args.page || 0; + const items = args.items || []; + + const result = await args.fetcher(page, limit); + + // If we get an empty array, we've reached the end + if (result.length === 0) { + return items; + } + + // If we get less than the limit, we've reached the end + if (result.length < limit) { + return [...result, ...items]; + } + + return fetchAllRecords({ + fetcher: args.fetcher, + fetchLimit: args.fetchLimit, + items: [...items, ...result], + page: page + 1, + }); +} + +export async function fetchTotalRecordCount(args: { + fetcher: (page: number, limit: number) => Promise; + fetchLimit?: number; +}) { + const limit = args.fetchLimit || 500; + + const estimatedCount = await estimateTotalRecordCount({ + fetcher: args.fetcher, + limit, + }); + const estimatedPages = Math.ceil(estimatedCount / limit); + const totalRecordCount = await exactTotalRecordCount({ + fetcher: args.fetcher, + limit, + startPage: estimatedPages, + }); + return totalRecordCount; +} + +function paginate(array: T[], offset: number, limit: number) { + let result: T[]; + + if (limit === -1) { + result = array.slice(offset); + } else { + result = array.slice(offset, offset + limit); + } + + return { + items: result, + offset, + totalRecordCount: array.length, + }; +} + +function search(array: T[], searchTerm: string, keys: (keyof T)[]) { + return array.filter((item) => + keys.some((key) => { + const value = item[key]; + return String(value ?? '') + .toLocaleLowerCase() + .includes(searchTerm.toLocaleLowerCase()); + }), + ); +} + +const counts = new Map(); + +setInterval( + () => { + counts.forEach((value, key) => { + if (value.expires < dayjs().unix()) { + counts.delete(key); + } + }); + }, + 1000 * 60 * 10, +); // 10 minutes + +async function getListCount( + options: { + expiration?: number; // Expiration in minutes + fetchLimit?: number; + query: Record; + serverId: string; + type: LibraryItem | string; + }, + fetcher?: (page: number, limit: number) => Promise, +) { + const key = getListCountKey(options); + const value = counts.get(key); + + if (fetcher && (!value || value.expires < dayjs().unix())) { + const totalRecordCount = await fetchTotalRecordCount({ + fetcher, + fetchLimit: options.fetchLimit, + }); + setListCount(key, totalRecordCount, options.expiration ?? 1440); + return totalRecordCount; + } + + return value?.count; +} + +function getListCountKey(options: { + query: Record; + serverId: string; + type: LibraryItem | string; +}) { + const hash = stringify(options.query as Record); + return `${options.serverId}::${options.type}::${hash}`; +} + +function invalidateListCount(key?: string) { + if (key) { + return counts.delete(key); + } + + return counts.clear(); +} + +function setListCount(key: string, count: number, expiration = 1440) { + counts.set(key, { count, expires: dayjs().unix() + expiration * 1000 * 60 }); +} + +const sortBy = { + album: (array: Album[], key: AlbumListSortOptions, order: ListSortOrder) => { + let value = array; + + switch (key) { + case AlbumListSortOptions.ALBUM_ARTIST: { + value = orderBy(value, ['artistId'], [order]); + break; + } + case AlbumListSortOptions.ARTIST: { + value = orderBy(value, ['artistId'], [order]); + break; + } + case AlbumListSortOptions.COMMUNITY_RATING: { + value = orderBy(value, ['userRating'], [order]); + break; + } + case AlbumListSortOptions.CRITIC_RATING: { + value = orderBy(value, ['userRating'], [order]); + break; + } + case AlbumListSortOptions.DATE_ADDED: { + value = orderBy(value, ['createdDate'], [order]); + break; + } + case AlbumListSortOptions.DATE_PLAYED: { + value = orderBy(value, ['userLastPlayedDate'], [order]); + break; + } + case AlbumListSortOptions.DURATION: { + value = orderBy(value, ['duration'], [order]); + break; + } + case AlbumListSortOptions.IS_FAVORITE: { + value = orderBy(value, ['userFavoriteDate', 'userFavorite'], [order, order]); + break; + } + case AlbumListSortOptions.NAME: { + value = orderBy(value, ['name'], [order]); + break; + } + case AlbumListSortOptions.PLAY_COUNT: { + value = orderBy(value, ['userPlayCount'], [order]); + break; + } + case AlbumListSortOptions.RANDOM: { + value = shuffle(value); + break; + } + case AlbumListSortOptions.RELEASE_DATE: { + value = orderBy(value, ['releaseDate'], [order]); + break; + } + case AlbumListSortOptions.TRACK_COUNT: { + value = orderBy(value, ['trackCount'], [order]); + break; + } + case AlbumListSortOptions.YEAR: { + value = orderBy(value, ['releaseYear'], [order]); + break; + } + } + + return value; + }, + artist: (array: Artist[], key: ArtistListSortOptions, order: ListSortOrder) => { + let value = array; + + switch (key) { + case ArtistListSortOptions.ALBUM_COUNT: { + value = orderBy(value, ['albumCount'], [order]); + break; + } + case ArtistListSortOptions.NAME: { + value = orderBy(value, ['name'], [order]); + break; + } + case ArtistListSortOptions.TRACK_COUNT: { + value = orderBy(value, ['trackCount'], [order]); + break; + } + } + + return value; + }, + genre: (array: Genre[], key: GenreListSortOptions, order: ListSortOrder) => { + let value = array; + + switch (key) { + case GenreListSortOptions.ALBUM_COUNT: { + value = orderBy(value, ['albumCount'], [order]); + break; + } + case GenreListSortOptions.NAME: { + value = orderBy(value, ['name'], [order]); + break; + } + case GenreListSortOptions.TRACK_COUNT: { + value = orderBy(value, ['trackCount'], [order]); + break; + } + } + + return value; + }, + playlist: (array: Playlist[], key: PlaylistListSortOptions, order: ListSortOrder) => { + let value = array; + + switch (key) { + case PlaylistListSortOptions.DURATION: { + value = orderBy(value, ['duration'], [order]); + break; + } + case PlaylistListSortOptions.NAME: { + value = orderBy(value, ['name'], [order]); + break; + } + case PlaylistListSortOptions.OWNER: { + value = orderBy(value, ['owner'], [order]); + break; + } + case PlaylistListSortOptions.PUBLIC: { + value = orderBy(value, ['isPublic'], [order]); + break; + } + case PlaylistListSortOptions.TRACK_COUNT: { + value = orderBy(value, ['trackCount'], [order]); + break; + } + } + + return value; + }, + song: (array: PlaylistSong[] | Song[], key: SongListSortOptions, order: ListSortOrder) => { + let value = array; + + switch (key) { + case SongListSortOptions.ALBUM: { + value = orderBy(value, ['album'], [order]); + break; + } + case SongListSortOptions.ALBUM_ARTIST: { + value = orderBy(value, ['artistId'], [order]); + break; + } + case SongListSortOptions.ARTIST: { + value = orderBy(value, ['artistId'], [order]); + break; + } + case SongListSortOptions.BPM: { + value = orderBy(value, ['bpm'], [order]); + break; + } + case SongListSortOptions.CHANNELS: { + value = orderBy(value, ['channels'], [order]); + break; + } + case SongListSortOptions.COMMENT: { + value = orderBy(value, ['comment'], [order]); + break; + } + case SongListSortOptions.DURATION: { + value = orderBy(value, ['duration'], [order]); + break; + } + case SongListSortOptions.GENRE: { + value = orderBy(value, ['genre'], [order]); + break; + } + case SongListSortOptions.ID: { + break; + } + case SongListSortOptions.IS_FAVORITE: { + value = orderBy(value, ['userFavoriteDate', 'userFavorite'], [order, order]); + break; + } + case SongListSortOptions.NAME: { + value = orderBy(value, ['name'], [order]); + break; + } + case SongListSortOptions.PLAY_COUNT: { + value = orderBy(value, ['userPlayCount'], [order]); + break; + } + case SongListSortOptions.RANDOM: { + value = shuffle(value); + break; + } + case SongListSortOptions.RATING: { + value = orderBy(value, ['userRating'], [order]); + break; + } + case SongListSortOptions.RECENTLY_ADDED: { + value = orderBy(value, ['recentlyAdded'], [order]); + break; + } + case SongListSortOptions.RECENTLY_PLAYED: { + value = orderBy(value, ['userLastPlayedDate'], [order]); + break; + } + case SongListSortOptions.RELEASE_DATE: { + value = orderBy(value, ['releaseYear'], [order]); + break; + } + case SongListSortOptions.YEAR: { + value = orderBy(value, ['releaseYear'], [order]); + break; + } + } + + return value; + }, + user: (array: User[], key: UserListSortOptions, order: ListSortOrder) => { + let value = array; + + switch (key) { + case UserListSortOptions.NAME: { + value = orderBy(value, ['name'], [order]); + break; + } + } + + return value; + }, +}; + +export const helpers = { + estimateTotalRecordCount, + exactTotalRecordCount, + fetchAllRecords, + fetchTotalRecordCount, + getListCount, + getListCountKey, + invalidateListCount, + paginate, + search, + setListCount, + sortBy, +}; diff --git a/src/shared/types/adapter/api-controller-types.ts b/src/shared/types/adapter/api-controller-types.ts index f6e650546..19b126583 100644 --- a/src/shared/types/adapter/api-controller-types.ts +++ b/src/shared/types/adapter/api-controller-types.ts @@ -6,14 +6,15 @@ import { AlbumListResponse, } from '/@/shared/types/domain/album-domain-types'; import { - AlbumArtistDetailRequest, - AlbumArtistDetailResponse, - AlbumArtistListRequest, - AlbumArtistListResponse, + ArtistDetailRequest, + ArtistDetailResponse, ArtistListRequest, ArtistListResponse, } from '/@/shared/types/domain/artist-domain-types'; -import { AuthenticationResponse } from '/@/shared/types/domain/auth-domain-types'; +import { + AuthenticationRequest, + AuthenticationResponse, +} from '/@/shared/types/domain/auth-domain-types'; import { GenreListRequest, GenreListResponse } from '/@/shared/types/domain/genre-domain-types'; import { LyricsRequest, @@ -21,6 +22,12 @@ import { StructuredLyric, StructuredLyricsRequest, } from '/@/shared/types/domain/lyric-domain-types'; +import { + FavoriteRequest, + FavoriteResponse, + RatingResponse, + SetRatingRequest, +} from '/@/shared/types/domain/metadata-domain-types'; import { TranscodingRequest } from '/@/shared/types/domain/player-domain-types'; import { AddToPlaylistArgs, @@ -64,18 +71,16 @@ import { import { TagRequest, TagsResponse } from '/@/shared/types/domain/tag-domain-types'; import { DownloadRequest, - FavoriteRequest, - FavoriteResponse, - RatingResponse, ScrobbleRequest, ScrobbleResponse, - SetRatingRequest, ShareItemRequest, ShareItemResponse, UserListRequest, UserListResponse, } from '/@/shared/types/domain/user-domain-types'; +export type ApiAuthentication = (args: AuthenticationRequest) => Promise; + export type ApiClientProps = { baseUrl?: string; cache?: 'default' | 'force-cache' | 'no-cache' | 'no-store' | 'only-if-cached' | 'reload'; @@ -119,21 +124,23 @@ export type ApiController = { getListCount?: ApiControllerFn; }; albumArtist: { - getDetail?: ApiControllerFn; - getList?: ApiControllerFn; - getListCount?: ApiControllerFn; - }; - artist: { + getDetail?: ApiControllerFn; getList?: ApiControllerFn; getListCount?: ApiControllerFn; }; - favorite: { - create?: ApiControllerFn; - delete?: ApiControllerFn; + artist: { + getDetail?: ApiControllerFn; + getList?: ApiControllerFn; + getListCount?: ApiControllerFn; }; genre: { getList?: ApiControllerFn; }; + metadata: { + addFavorite?: ApiControllerFn; + removeFavorite?: ApiControllerFn; + setRating?: ApiControllerFn; + }; musicFolder: { getList?: ApiControllerFn; }; @@ -150,14 +157,6 @@ export type ApiController = { update?: ApiControllerFn; }; server: { - authenticate: ( - url: string, - body: { legacy?: boolean; password: string; username: string }, - ) => Promise; - getRoles?: ApiControllerFn< - BaseEndpointArgs, - Array - >; getServerInfo?: ApiControllerFn; getTags?: ApiControllerFn; getTranscodingUrl?: ApiControllerFn; @@ -177,7 +176,7 @@ export type ApiController = { }; user: { getList?: ApiControllerFn; - setRating?: ApiControllerFn; + getListCount?: ApiControllerFn; shareItem?: ApiControllerFn; }; }; @@ -189,7 +188,6 @@ export interface ApiControllerError { export type ApiControllerFn = ( request: TRequest, - server: ServerListItem, options?: ApiClientProps, ) => Promise<[ApiControllerError, null] | [null, TResponse]>; @@ -198,18 +196,19 @@ export type BaseEndpointArgs = { signal?: AbortSignal; }; }; -export interface BasePaginatedResponse { - error?: any | string; - items: T; - startIndex: number; - totalRecordCount: null | number; -} - -export interface BaseQuery { +export interface BasePaginatedQuery { + limit: number; + offset: number; sortBy: T; sortOrder: ListSortOrder; } +export interface BasePaginatedResponse { + items: T; + offset: number; + totalRecordCount: null | number; +} + export type ExtractControllerResponse = T extends ApiControllerFn ? R : never; export const API_CLIENT_NAME = 'Feishin'; diff --git a/src/shared/types/domain/album-domain-types.ts b/src/shared/types/domain/album-domain-types.ts index c00d94fdd..2e9f96b7c 100644 --- a/src/shared/types/domain/album-domain-types.ts +++ b/src/shared/types/domain/album-domain-types.ts @@ -6,7 +6,10 @@ import { JFAlbumListSort } from '/@/shared/api/jellyfin.types'; import { jfType } from '/@/shared/api/jellyfin/jellyfin-types'; import { NDAlbumListSort } from '/@/shared/api/navidrome.types'; import { ndType } from '/@/shared/api/navidrome/navidrome-types'; -import { BasePaginatedResponse, BaseQuery } from '/@/shared/types/adapter/api-controller-types'; +import { + BasePaginatedQuery, + BasePaginatedResponse, +} from '/@/shared/types/adapter/api-controller-types'; import { RelatedArtist } from '/@/shared/types/domain/artist-domain-types'; import { RelatedGenre } from '/@/shared/types/domain/genre-domain-types'; import { ServerType } from '/@/shared/types/domain/server-domain-types'; @@ -75,24 +78,18 @@ export enum AlbumListSort { YEAR = 'year', } -export interface AlbumListQuery extends BaseQuery { - _custom?: { - jellyfin?: Partial>; - navidrome?: Partial>; - }; +export interface AlbumListQuery extends BasePaginatedQuery { artistIds?: string[]; compilation?: boolean; favorite?: boolean; genres?: string[]; - limit?: number; maxYear?: number; minYear?: number; musicFolderId?: string; searchTerm?: string; - startIndex: number; } -export type AlbumListRequest = { query: AlbumListQuery }; +export type AlbumListRequest = { query: AlbumListQuery; totalRecordCount?: number }; export type AlbumListResponse = BasePaginatedResponse | null | undefined; diff --git a/src/shared/types/domain/api-domain-types.ts b/src/shared/types/domain/api-domain-types.ts index ed2c66fa9..36afd7a83 100644 --- a/src/shared/types/domain/api-domain-types.ts +++ b/src/shared/types/domain/api-domain-types.ts @@ -7,8 +7,8 @@ import { AlbumListResponse, } from '/@/shared/types/domain/album-domain-types'; import { - AlbumArtistDetailRequest, - AlbumArtistDetailResponse, + ArtistDetailRequest, + ArtistDetailResponse, AlbumArtistListRequest, AlbumArtistListResponse, ArtistListRequest, @@ -62,17 +62,19 @@ import { import { TagRequest, TagsResponse } from '/@/shared/types/domain/tag-domain-types'; import { DownloadRequest, - FavoriteRequest, - FavoriteResponse, - RatingResponse, ScrobbleRequest, ScrobbleResponse, - SetRatingRequest, ShareItemRequest, ShareItemResponse, UserListRequest, UserListResponse, } from '/@/shared/types/domain/user-domain-types'; +import { + FavoriteRequest, + FavoriteResponse, + RatingResponse, + SetRatingRequest, +} from './metadata-domain-types'; export const instanceOfCancellationError = (error: any) => { return 'revert' in error; @@ -88,7 +90,7 @@ export type ControllerEndpoint = { createPlaylist: (args: CreatePlaylistRequest) => Promise; deleteFavorite: (args: FavoriteRequest) => Promise; deletePlaylist: (args: DeletePlaylistRequest) => Promise; - getAlbumArtistDetail: (args: AlbumArtistDetailRequest) => Promise; + getAlbumArtistDetail: (args: ArtistDetailRequest) => Promise; getAlbumArtistList: (args: AlbumArtistListRequest) => Promise; getAlbumArtistListCount: (args: AlbumArtistListRequest) => Promise; getAlbumDetail: (args: AlbumDetailRequest) => Promise; diff --git a/src/shared/types/domain/artist-domain-types.ts b/src/shared/types/domain/artist-domain-types.ts index a1e8bda79..6b10c54ea 100644 --- a/src/shared/types/domain/artist-domain-types.ts +++ b/src/shared/types/domain/artist-domain-types.ts @@ -1,12 +1,12 @@ import { orderBy } from 'lodash'; -import { z } from 'zod'; import i18n from '/@/i18n/i18n'; import { JFAlbumArtistListSort, JFArtistListSort } from '/@/shared/api/jellyfin.types'; -import { jfType } from '/@/shared/api/jellyfin/jellyfin-types'; import { NDAlbumArtistListSort } from '/@/shared/api/navidrome.types'; -import { ndType } from '/@/shared/api/navidrome/navidrome-types'; -import { BasePaginatedResponse, BaseQuery } from '/@/shared/types/adapter/api-controller-types'; +import { + BasePaginatedQuery, + BasePaginatedResponse, +} from '/@/shared/types/adapter/api-controller-types'; import { RelatedGenre } from '/@/shared/types/domain/genre-domain-types'; import { ServerType } from '/@/shared/types/domain/server-domain-types'; import { LibraryItem, ListSortOrder } from '/@/shared/types/domain/shared-domain-types'; @@ -121,23 +121,17 @@ export enum ArtistListSort { export type AlbumArtistDetailQuery = { id: string }; -export type AlbumArtistDetailRequest = { query: AlbumArtistDetailQuery }; +export type ArtistDetailRequest = { query: AlbumArtistDetailQuery }; -export type AlbumArtistDetailResponse = Artist | null; +export type ArtistDetailResponse = Artist | null; -export interface ArtistListQuery extends BaseQuery { - _custom?: { - jellyfin?: Partial>; - navidrome?: Partial>; - }; - limit?: number; +export interface ArtistListQuery extends BasePaginatedQuery { musicFolderId?: string; role?: string; searchTerm?: string; - startIndex: number; } -export type ArtistListRequest = { query: ArtistListQuery }; +export type ArtistListRequest = { query: ArtistListQuery; totalRecordCount?: number }; export type ArtistListResponse = BasePaginatedResponse | null | undefined; type ArtistListSortMap = { @@ -201,21 +195,6 @@ export enum AlbumArtistListSort { SONG_COUNT = 'songCount', } -export interface AlbumArtistListQuery extends BaseQuery { - _custom?: { - jellyfin?: Partial>; - navidrome?: Partial>; - }; - limit?: number; - musicFolderId?: string; - searchTerm?: string; - startIndex: number; -} - -export type AlbumArtistListRequest = { query: AlbumArtistListQuery }; - -export type AlbumArtistListResponse = BasePaginatedResponse | null | undefined; - export type ArtistInfoQuery = { artistId: string; limit: number; diff --git a/src/shared/types/domain/auth-domain-types.ts b/src/shared/types/domain/auth-domain-types.ts index bc44fb149..6ae1f37bb 100644 --- a/src/shared/types/domain/auth-domain-types.ts +++ b/src/shared/types/domain/auth-domain-types.ts @@ -1,6 +1,12 @@ +import { UserPermissions } from '/@/shared/types/domain/user-domain-types'; + +export type AuthenticationRequest = { + body: { password: string; username: string }; + url: string; +}; + export type AuthenticationResponse = { credential: string; - ndCredential?: string; - userId: null | string; + permissions: UserPermissions; username: string; }; diff --git a/src/shared/types/domain/genre-domain-types.ts b/src/shared/types/domain/genre-domain-types.ts index 2ca32142c..5d7742e81 100644 --- a/src/shared/types/domain/genre-domain-types.ts +++ b/src/shared/types/domain/genre-domain-types.ts @@ -1,10 +1,10 @@ import i18n from '/@/i18n/i18n'; -import { JFGenreListSort } from '/@/shared/api/jellyfin.types'; -import { NDGenreListSort } from '/@/shared/api/navidrome.types'; -import { BasePaginatedResponse, BaseQuery } from '/@/shared/types/adapter/api-controller-types'; +import { + BasePaginatedQuery, + BasePaginatedResponse, +} from '/@/shared/types/adapter/api-controller-types'; import { ServerType } from '/@/shared/types/domain/server-domain-types'; import { LibraryItem } from '/@/shared/types/domain/shared-domain-types'; -import { UserListSort } from '/@/shared/types/domain/user-domain-types'; export enum GenreListSortOptions { ALBUM_COUNT = 'albumCount', @@ -29,18 +29,12 @@ export type Genre = { songCount: null | number; }; -export interface GenreListQuery extends BaseQuery { - _custom?: { - jellyfin?: null; - navidrome?: null; - }; - limit?: number; +export interface GenreListQuery extends BasePaginatedQuery { musicFolderId?: string; searchTerm?: string; - startIndex: number; } -export type GenreListRequest = { query: GenreListQuery }; +export type GenreListRequest = { query: GenreListQuery; totalRecordCount?: number }; export type GenreListResponse = BasePaginatedResponse | null | undefined; @@ -49,24 +43,3 @@ export type RelatedGenre = { imageUrl: null | string; name: string; }; - -type GenreListSortMap = { - jellyfin: Record; - navidrome: Record; - subsonic: Record; -}; - -export const genreListSortMap: GenreListSortMap = { - jellyfin: { - name: JFGenreListSort.NAME, - }, - navidrome: { - name: NDGenreListSort.NAME, - }, - subsonic: { - name: undefined, - }, -}; -export enum GenreListSort { - NAME = 'name', -} diff --git a/src/shared/types/domain/metadata-domain-types.ts b/src/shared/types/domain/metadata-domain-types.ts new file mode 100644 index 000000000..afa43c941 --- /dev/null +++ b/src/shared/types/domain/metadata-domain-types.ts @@ -0,0 +1,19 @@ +import { LibraryItem } from '/@/shared/types/domain/shared-domain-types'; + +export type FavoriteQuery = { + id: string[]; + type: LibraryItem; +}; + +export type FavoriteRequest = { query: FavoriteQuery; serverId?: string }; + +export type FavoriteResponse = null; + +export type RatingQuery = { + id: string[]; + rating: number; +}; + +export type RatingResponse = null; + +export type SetRatingRequest = { query: RatingQuery; serverId?: string }; diff --git a/src/shared/types/domain/playlist-domain-types.ts b/src/shared/types/domain/playlist-domain-types.ts index d3fe6b1f8..3a4447717 100644 --- a/src/shared/types/domain/playlist-domain-types.ts +++ b/src/shared/types/domain/playlist-domain-types.ts @@ -1,19 +1,13 @@ -import { z } from 'zod'; - import i18n from '/@/i18n/i18n'; -import { JFPlaylistListSort } from '/@/shared/api/jellyfin.types'; -import { jfType } from '/@/shared/api/jellyfin/jellyfin-types'; -import { NDPlaylistListSort } from '/@/shared/api/navidrome.types'; -import { ndType } from '/@/shared/api/navidrome/navidrome-types'; import { BaseEndpointArgs, + BasePaginatedQuery, BasePaginatedResponse, - BaseQuery, } from '/@/shared/types/adapter/api-controller-types'; import { Genre, RelatedGenre } from '/@/shared/types/domain/genre-domain-types'; import { ServerType } from '/@/shared/types/domain/server-domain-types'; -import { LibraryItem, ListSortOrder } from '/@/shared/types/domain/shared-domain-types'; -import { Song, SongListSort } from '/@/shared/types/domain/song-domain-types'; +import { LibraryItem } from '/@/shared/types/domain/shared-domain-types'; +import { Song, SongListSortOptions } from '/@/shared/types/domain/song-domain-types'; export enum PlaylistListSortOptions { DURATION = 'duration', @@ -33,15 +27,6 @@ export const PlaylistListSortOptionsLabels = { [PlaylistListSortOptions.UPDATED_AT]: i18n.t('filter.updatedAt'), }; -export enum PlaylistListSort { - DURATION = 'duration', - NAME = 'name', - OWNER = 'owner', - PUBLIC = 'public', - SONG_COUNT = 'songCount', - UPDATED_AT = 'updatedAt', -} - export type AddToPlaylistArgs = BaseEndpointArgs & { body: AddToPlaylistBody; query: AddToPlaylistQuery; @@ -88,6 +73,17 @@ export type DeletePlaylistRequest = { export type DeletePlaylistResponse = null | undefined; +export type MoveItemQuery = { + endingIndex: number; + playlistId: string; + startingIndex: number; + trackId: string; +}; + +export type MoveItemRequest = { + query: MoveItemQuery; +}; + export type Playlist = { _itemType: LibraryItem.PLAYLIST; _serverId: string; @@ -109,17 +105,19 @@ export type Playlist = { updatedDate: null | string; }; -export interface PlaylistListQuery extends BaseQuery { - _custom?: { - jellyfin?: Partial>; - navidrome?: Partial>; - }; - limit?: number; +export type PlaylistDetailQuery = { + id: string; +}; + +export type PlaylistDetailRequest = { query: PlaylistDetailQuery }; + +export type PlaylistDetailResponse = Playlist; + +export interface PlaylistListQuery extends BasePaginatedQuery { searchTerm?: string; - startIndex: number; } -export type PlaylistListRequest = { query: PlaylistListQuery }; +export type PlaylistListRequest = { query: PlaylistListQuery; totalRecordCount?: number }; export type PlaylistListResponse = BasePaginatedResponse | null | undefined; @@ -127,6 +125,41 @@ export type PlaylistSong = Song & { playlistItemId: string; }; +export type PlaylistSongListQuery = BasePaginatedQuery & { + id: string; +}; + +export type PlaylistSongListRequest = { query: PlaylistSongListQuery; totalRecordCount?: number }; + +// export const playlistListSortMap: PlaylistListSortMap = { +// jellyfin: { +// duration: JFPlaylistListSort.DURATION, +// name: JFPlaylistListSort.NAME, +// owner: undefined, +// public: undefined, +// songCount: JFPlaylistListSort.SONG_COUNT, +// updatedAt: undefined, +// }, +// navidrome: { +// duration: NDPlaylistListSort.DURATION, +// name: NDPlaylistListSort.NAME, +// owner: NDPlaylistListSort.OWNER, +// public: NDPlaylistListSort.PUBLIC, +// songCount: NDPlaylistListSort.SONG_COUNT, +// updatedAt: NDPlaylistListSort.UPDATED_AT, +// }, +// subsonic: { +// duration: undefined, +// name: undefined, +// owner: undefined, +// public: undefined, +// songCount: undefined, +// updatedAt: undefined, +// }, +// }; + +export type PlaylistSongListResponse = BasePaginatedResponse; + export type RemoveFromPlaylistQuery = { id: string; songId: string[]; @@ -165,66 +198,3 @@ export type UpdatePlaylistRequest = { }; export type UpdatePlaylistResponse = null | undefined; - -type PlaylistListSortMap = { - jellyfin: Record; - navidrome: Record; - subsonic: Record; -}; - -export const playlistListSortMap: PlaylistListSortMap = { - jellyfin: { - duration: JFPlaylistListSort.DURATION, - name: JFPlaylistListSort.NAME, - owner: undefined, - public: undefined, - songCount: JFPlaylistListSort.SONG_COUNT, - updatedAt: undefined, - }, - navidrome: { - duration: NDPlaylistListSort.DURATION, - name: NDPlaylistListSort.NAME, - owner: NDPlaylistListSort.OWNER, - public: NDPlaylistListSort.PUBLIC, - songCount: NDPlaylistListSort.SONG_COUNT, - updatedAt: NDPlaylistListSort.UPDATED_AT, - }, - subsonic: { - duration: undefined, - name: undefined, - owner: undefined, - public: undefined, - songCount: undefined, - updatedAt: undefined, - }, -}; -export type MoveItemQuery = { - endingIndex: number; - playlistId: string; - startingIndex: number; - trackId: string; -}; - -export type MoveItemRequest = { - query: MoveItemQuery; -}; - -export type PlaylistDetailQuery = { - id: string; -}; - -export type PlaylistDetailRequest = { query: PlaylistDetailQuery }; - -export type PlaylistDetailResponse = Playlist; - -export type PlaylistSongListQuery = { - id: string; - limit?: number; - sortBy?: SongListSort; - sortOrder?: ListSortOrder; - startIndex: number; -}; - -export type PlaylistSongListRequest = { query: PlaylistSongListQuery }; - -export type PlaylistSongListResponse = BasePaginatedResponse | null | undefined; diff --git a/src/shared/types/domain/server-domain-types.ts b/src/shared/types/domain/server-domain-types.ts index 64a9569c6..7d142000f 100644 --- a/src/shared/types/domain/server-domain-types.ts +++ b/src/shared/types/domain/server-domain-types.ts @@ -46,11 +46,9 @@ export type ServerListItem = { features?: ServerFeatures; id: string; name: string; - ndCredential?: string; savePassword?: boolean; type: ServerType; url: string; - userId: null | string; username: string; version?: string; }; diff --git a/src/shared/types/domain/shared-domain-types.ts b/src/shared/types/domain/shared-domain-types.ts index 4d9ead745..89d6208e7 100644 --- a/src/shared/types/domain/shared-domain-types.ts +++ b/src/shared/types/domain/shared-domain-types.ts @@ -13,11 +13,12 @@ export enum LibraryItem { GENRE = 'genre', PLAYLIST = 'playlist', SONG = 'song', + USER = 'user', } export enum ListSortOrder { - ASC = 'ASC', - DESC = 'DESC', + ASC = 'asc', + DESC = 'desc', } export type AnyLibraryItem = Album | Artist | Artist | Playlist | QueueSong | Song; diff --git a/src/shared/types/domain/song-domain-types.ts b/src/shared/types/domain/song-domain-types.ts index a11432b89..8ee222450 100644 --- a/src/shared/types/domain/song-domain-types.ts +++ b/src/shared/types/domain/song-domain-types.ts @@ -6,7 +6,10 @@ import { JFSongListSort } from '/@/shared/api/jellyfin.types'; import { jfType } from '/@/shared/api/jellyfin/jellyfin-types'; import { NDSongListSort } from '/@/shared/api/navidrome.types'; import { ndType } from '/@/shared/api/navidrome/navidrome-types'; -import { BasePaginatedResponse, BaseQuery } from '/@/shared/types/adapter/api-controller-types'; +import { + BasePaginatedQuery, + BasePaginatedResponse, +} from '/@/shared/types/adapter/api-controller-types'; import { RelatedArtist } from '/@/shared/types/domain/artist-domain-types'; import { RelatedGenre } from '/@/shared/types/domain/genre-domain-types'; import { Played, QueueSong } from '/@/shared/types/domain/player-domain-types'; @@ -134,27 +137,21 @@ export type Song = { userRating: null | number; }; -export interface SongListQuery extends BaseQuery { - _custom?: { - jellyfin?: Partial>; - navidrome?: Partial>; - }; +export interface SongListQuery extends BasePaginatedQuery { albumArtistIds?: string[]; albumIds?: string[]; artistIds?: string[]; favorite?: boolean; genreIds?: string[]; imageSize?: number; - limit?: number; maxYear?: number; minYear?: number; musicFolderId?: string; role?: string; searchTerm?: string; - startIndex: number; } -export type SongListRequest = { query: SongListQuery }; +export type SongListRequest = { query: SongListQuery; totalRecordCount?: number }; export type SongListResponse = BasePaginatedResponse | null | undefined; type SongListSortMap = { @@ -240,7 +237,7 @@ export type RandomSongListQuery = { played: Played; }; -export type RandomSongListRequest = { query: RandomSongListQuery }; +export type RandomSongListRequest = { query: RandomSongListQuery; totalRecordCount?: number }; export type RandomSongListResponse = SongListResponse; @@ -264,7 +261,7 @@ export type TopSongListQuery = { limit?: number; }; -export type TopSongListRequest = { query: TopSongListQuery }; +export type TopSongListRequest = { query: TopSongListQuery; totalRecordCount?: number }; export type TopSongListResponse = BasePaginatedResponse | null | undefined; diff --git a/src/shared/types/domain/user-domain-types.ts b/src/shared/types/domain/user-domain-types.ts index c3875bf38..68f02b3e0 100644 --- a/src/shared/types/domain/user-domain-types.ts +++ b/src/shared/types/domain/user-domain-types.ts @@ -1,71 +1,17 @@ import i18n from '/@/i18n/i18n'; -import { NDUserListSort } from '/@/shared/api/navidrome.types'; -import { BasePaginatedResponse, BaseQuery } from '/@/shared/types/adapter/api-controller-types'; -import { AnyLibraryItems, LibraryItem } from '/@/shared/types/domain/shared-domain-types'; +import { + BasePaginatedQuery, + BasePaginatedResponse, +} from '/@/shared/types/adapter/api-controller-types'; +import { ServerType } from '/@/shared/types/domain/server-domain-types'; +import { LibraryItem } from '/@/shared/types/domain/shared-domain-types'; export enum UserListSortOptions { - CREATED_AT = 'createdAt', - EMAIL = 'email', NAME = 'name', - UPDATED_AT = 'updatedAt', } export const UserListSortOptionsLabels = { - [UserListSortOptions.CREATED_AT]: i18n.t('filter.createdAt'), - [UserListSortOptions.EMAIL]: i18n.t('filter.email'), [UserListSortOptions.NAME]: i18n.t('filter.name'), - [UserListSortOptions.UPDATED_AT]: i18n.t('filter.updatedAt'), -}; - -export type FavoriteQuery = { - id: string[]; - type: LibraryItem; -}; - -export type FavoriteRequest = { query: FavoriteQuery; serverId?: string }; - -export type FavoriteResponse = null; - -export type RatingQuery = { - item: AnyLibraryItems; - rating: number; -}; - -export type RatingResponse = null; - -export type SetRatingRequest = { query: RatingQuery; serverId?: string }; - -export interface UserListQuery extends BaseQuery { - _custom?: { - navidrome?: { - owner_id?: string; - }; - }; - limit?: number; - searchTerm?: string; - startIndex: number; -} - -export type UserListRequest = { query: UserListQuery }; - -export type UserListResponse = BasePaginatedResponse | null | undefined; - -type UserListSortMap = { - jellyfin: Record; - navidrome: Record; - subsonic: Record; -}; - -export const userListSortMap: UserListSortMap = { - jellyfin: { - name: undefined, - }, - navidrome: { - name: NDUserListSort.NAME, - }, - subsonic: { - name: undefined, - }, }; export enum UserListSort { @@ -93,20 +39,41 @@ export type ShareItemBody = { description: string; downloadable: boolean; expires: number; - resourceIds: string; + resourceIds: string[]; resourceType: string; }; export type ShareItemRequest = { body: ShareItemBody; serverId?: string }; -export type ShareItemResponse = null | { id: string }; +export type ShareItemResponse = null | { id: string; url: string }; export type User = { - createdAt: null | string; - email: null | string; + _itemType: LibraryItem.USER; + _serverId: string; + _serverType: ServerType; id: string; - isAdmin: boolean | null; - lastLoginAt: null | string; - name: string; - updatedAt: null | string; + permissions: UserPermissions; + username: string; +}; + +export interface UserListQuery extends BasePaginatedQuery { + searchTerm?: string; +} + +export type UserListRequest = { query: UserListQuery; totalRecordCount?: number }; + +export type UserListResponse = BasePaginatedResponse; + +export type UserPermissions = { + 'jukebox.manage': boolean; // Allow managing the jukebox + 'media.download': boolean; // Allow downloading media + 'media.folder': string[]; // Viewable folders + 'media.share': boolean; // Allow sharing media + 'media.stream': boolean; // Allow streaming media + 'media.upload': boolean; // Allow uploading media + 'playlist.create': boolean; // Allow creating playlists + 'playlist.delete': boolean; // Allow deleting playlists + 'playlist.edit': boolean; // Allow editing playlists + 'server.admin': boolean; // Allow managing the server (user management / server settings) + 'user.edit': boolean; // Allow editing own user account }; diff --git a/src/shared/utils/random-string.ts b/src/shared/utils/random-string.ts new file mode 100644 index 000000000..1c32859d4 --- /dev/null +++ b/src/shared/utils/random-string.ts @@ -0,0 +1,9 @@ +export const randomString = (length?: number) => { + const charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let string = ''; + for (let i = 0; i < (length || 12); i += 1) { + const randomPoz = Math.floor(Math.random() * charSet.length); + string += charSet.substring(randomPoz, randomPoz + 1); + } + return string; +};