diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3997bd640..9dc7b0de6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -552,7 +552,7 @@ "column": { "album": "album", "albumArtist": "album artist", - "albumCount": "$t(entity.album_other)", + "albumCount": "$t(entity.album_one)", "artist": "$t(entity.artist_one)", "biography": "biography", "bitrate": "bitrate", diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 000000000..162a9ffad --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,41 @@ +import dayjs from 'dayjs'; + +const reset = '\x1b[0m'; + +const baseLog = (errorType: 'error' | 'info' | 'success' | 'warn') => { + let logString = ''; + switch (errorType) { + case 'error': + logString = '\x1b[31m[ERROR] '; + break; + case 'info': + logString = '\x1b[34m[INFO] '; + break; + case 'success': + logString = '\x1b[32m[SUCCESS] '; + break; + case 'warn': + logString = '\x1b[33m[WARNING] '; + break; + default: + logString = '\x1b[34m[INFO] '; + break; + } + + return (text: string, options?: { context?: Record; toast?: boolean }): void => { + // const { toast } = options || {}; + const now = dayjs().toISOString(); + console.log( + `${logString}${now}: ${text} | ${ + options?.context && JSON.stringify(options.context) + }${reset}`, + ); + }; +}; + +export const fsLog = { + error: baseLog('error'), + info: baseLog('info'), + success: baseLog('success'), + warn: baseLog('warn'), +}; diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 196a848c1..294e6b000 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -1,100 +1,38 @@ -import { useAuthStore } from '/@/renderer/store'; -import { toast } from '/@/renderer/components/toast/index'; +import { RandomSongListArgs } from './types'; +import i18n from '/@/i18n/i18n'; +import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller'; +import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller'; +import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; import type { - AlbumDetailArgs, - AlbumListArgs, - SongListArgs, - SongDetailArgs, + AddToPlaylistArgs, AlbumArtistDetailArgs, AlbumArtistListArgs, - SetRatingArgs, - GenreListArgs, + AlbumDetailArgs, + AlbumListArgs, + ArtistListArgs, + ControllerEndpoint, CreatePlaylistArgs, DeletePlaylistArgs, + FavoriteArgs, + GenreListArgs, + LyricsArgs, + MusicFolderListArgs, PlaylistDetailArgs, PlaylistListArgs, - MusicFolderListArgs, PlaylistSongListArgs, - ArtistListArgs, - UpdatePlaylistArgs, - UserListArgs, - FavoriteArgs, - TopSongListArgs, - AddToPlaylistArgs, - AddToPlaylistResponse, RemoveFromPlaylistArgs, - RemoveFromPlaylistResponse, ScrobbleArgs, - ScrobbleResponse, - AlbumArtistDetailResponse, - FavoriteResponse, - CreatePlaylistResponse, - AlbumArtistListResponse, - AlbumDetailResponse, - AlbumListResponse, - ArtistListResponse, - GenreListResponse, - MusicFolderListResponse, - PlaylistDetailResponse, - PlaylistListResponse, - RatingResponse, - SongDetailResponse, - SongListResponse, - TopSongListResponse, - UpdatePlaylistResponse, - UserListResponse, - AuthenticationResponse, SearchArgs, - SearchResponse, - LyricsArgs, - LyricsResponse, + SetRatingArgs, + SongDetailArgs, + SongListArgs, + TopSongListArgs, + UpdatePlaylistArgs, + UserListArgs, } from '/@/renderer/api/types'; +import { toast } from '/@/renderer/components/toast/index'; +import { useAuthStore } from '/@/renderer/store'; import { ServerType } from '/@/renderer/types'; -import { DeletePlaylistResponse, RandomSongListArgs } from './types'; -import { ndController } from '/@/renderer/api/navidrome/navidrome-controller'; -import { ssController } from '/@/renderer/api/subsonic/subsonic-controller'; -import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller'; -import i18n from '/@/i18n/i18n'; - -export type ControllerEndpoint = Partial<{ - addToPlaylist: (args: AddToPlaylistArgs) => Promise; - authenticate: ( - url: string, - body: { password: string; username: string }, - ) => Promise; - clearPlaylist: () => void; - createFavorite: (args: FavoriteArgs) => Promise; - createPlaylist: (args: CreatePlaylistArgs) => Promise; - deleteFavorite: (args: FavoriteArgs) => Promise; - deletePlaylist: (args: DeletePlaylistArgs) => Promise; - getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise; - getAlbumArtistList: (args: AlbumArtistListArgs) => Promise; - getAlbumDetail: (args: AlbumDetailArgs) => Promise; - getAlbumList: (args: AlbumListArgs) => Promise; - getArtistDetail: () => void; - getArtistInfo: (args: any) => void; - getArtistList: (args: ArtistListArgs) => Promise; - getFavoritesList: () => void; - getFolderItemList: () => void; - getFolderList: () => void; - getFolderSongs: () => void; - getGenreList: (args: GenreListArgs) => Promise; - getLyrics: (args: LyricsArgs) => Promise; - getMusicFolderList: (args: MusicFolderListArgs) => Promise; - getPlaylistDetail: (args: PlaylistDetailArgs) => Promise; - getPlaylistList: (args: PlaylistListArgs) => Promise; - getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; - getRandomSongList: (args: RandomSongListArgs) => Promise; - getSongDetail: (args: SongDetailArgs) => Promise; - getSongList: (args: SongListArgs) => Promise; - getTopSongs: (args: TopSongListArgs) => Promise; - getUserList: (args: UserListArgs) => Promise; - removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; - scrobble: (args: ScrobbleArgs) => Promise; - search: (args: SearchArgs) => Promise; - setRating: (args: SetRatingArgs) => Promise; - updatePlaylist: (args: UpdatePlaylistArgs) => Promise; -}>; type ApiController = { jellyfin: ControllerEndpoint; @@ -103,110 +41,9 @@ type ApiController = { }; const endpoints: ApiController = { - jellyfin: { - addToPlaylist: jfController.addToPlaylist, - authenticate: jfController.authenticate, - clearPlaylist: undefined, - createFavorite: jfController.createFavorite, - createPlaylist: jfController.createPlaylist, - deleteFavorite: jfController.deleteFavorite, - deletePlaylist: jfController.deletePlaylist, - getAlbumArtistDetail: jfController.getAlbumArtistDetail, - getAlbumArtistList: jfController.getAlbumArtistList, - getAlbumDetail: jfController.getAlbumDetail, - getAlbumList: jfController.getAlbumList, - getArtistDetail: undefined, - getArtistInfo: undefined, - getArtistList: undefined, - getFavoritesList: undefined, - getFolderItemList: undefined, - getFolderList: undefined, - getFolderSongs: undefined, - getGenreList: jfController.getGenreList, - getLyrics: jfController.getLyrics, - getMusicFolderList: jfController.getMusicFolderList, - getPlaylistDetail: jfController.getPlaylistDetail, - getPlaylistList: jfController.getPlaylistList, - getPlaylistSongList: jfController.getPlaylistSongList, - getRandomSongList: jfController.getRandomSongList, - getSongDetail: jfController.getSongDetail, - getSongList: jfController.getSongList, - getTopSongs: jfController.getTopSongList, - getUserList: undefined, - removeFromPlaylist: jfController.removeFromPlaylist, - scrobble: jfController.scrobble, - search: jfController.search, - setRating: undefined, - updatePlaylist: jfController.updatePlaylist, - }, - navidrome: { - addToPlaylist: ndController.addToPlaylist, - authenticate: ndController.authenticate, - clearPlaylist: undefined, - createFavorite: ssController.createFavorite, - createPlaylist: ndController.createPlaylist, - deleteFavorite: ssController.removeFavorite, - deletePlaylist: ndController.deletePlaylist, - getAlbumArtistDetail: ndController.getAlbumArtistDetail, - getAlbumArtistList: ndController.getAlbumArtistList, - getAlbumDetail: ndController.getAlbumDetail, - getAlbumList: ndController.getAlbumList, - getArtistDetail: undefined, - getArtistInfo: undefined, - getArtistList: undefined, - getFavoritesList: undefined, - getFolderItemList: undefined, - getFolderList: undefined, - getFolderSongs: undefined, - getGenreList: ndController.getGenreList, - getLyrics: undefined, - getMusicFolderList: ssController.getMusicFolderList, - getPlaylistDetail: ndController.getPlaylistDetail, - getPlaylistList: ndController.getPlaylistList, - getPlaylistSongList: ndController.getPlaylistSongList, - getRandomSongList: ssController.getRandomSongList, - getSongDetail: ndController.getSongDetail, - getSongList: ndController.getSongList, - getTopSongs: ssController.getTopSongList, - getUserList: ndController.getUserList, - removeFromPlaylist: ndController.removeFromPlaylist, - scrobble: ssController.scrobble, - search: ssController.search3, - setRating: ssController.setRating, - updatePlaylist: ndController.updatePlaylist, - }, - subsonic: { - authenticate: ssController.authenticate, - clearPlaylist: undefined, - createFavorite: ssController.createFavorite, - createPlaylist: undefined, - deleteFavorite: ssController.removeFavorite, - deletePlaylist: undefined, - getAlbumArtistDetail: undefined, - getAlbumArtistList: undefined, - getAlbumDetail: undefined, - getAlbumList: undefined, - getArtistDetail: undefined, - getArtistInfo: undefined, - getArtistList: undefined, - getFavoritesList: undefined, - getFolderItemList: undefined, - getFolderList: undefined, - getFolderSongs: undefined, - getGenreList: undefined, - getLyrics: undefined, - getMusicFolderList: ssController.getMusicFolderList, - getPlaylistDetail: undefined, - getPlaylistList: undefined, - getSongDetail: undefined, - getSongList: undefined, - getTopSongs: ssController.getTopSongList, - getUserList: undefined, - scrobble: ssController.scrobble, - search: ssController.search3, - setRating: undefined, - updatePlaylist: undefined, - }, + jellyfin: JellyfinController, + navidrome: NavidromeController, + subsonic: SubsonicController, }; const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => { @@ -259,6 +96,15 @@ const getAlbumList = async (args: AlbumListArgs) => { )?.(args); }; +const getAlbumListCount = async (args: AlbumListArgs) => { + return ( + apiController( + 'getAlbumListCount', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getAlbumListCount'] + )?.(args); +}; + const getAlbumDetail = async (args: AlbumDetailArgs) => { return ( apiController( @@ -277,6 +123,15 @@ const getSongList = async (args: SongListArgs) => { )?.(args); }; +const getSongListCount = async (args: SongListArgs) => { + return ( + apiController( + 'getSongListCount', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getSongListCount'] + )?.(args); +}; + const getSongDetail = async (args: SongDetailArgs) => { return ( apiController( @@ -322,6 +177,15 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs) => { )?.(args); }; +const getAlbumArtistListCount = async (args: AlbumArtistListArgs) => { + return ( + apiController( + 'getAlbumArtistListCount', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getAlbumArtistListCount'] + )?.(args); +}; + const getArtistList = async (args: ArtistListArgs) => { return ( apiController( @@ -340,6 +204,15 @@ const getPlaylistList = async (args: PlaylistListArgs) => { )?.(args); }; +const getPlaylistListCount = async (args: PlaylistListArgs) => { + return ( + apiController( + 'getPlaylistListCount', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getPlaylistListCount'] + )?.(args); +}; + const createPlaylist = async (args: CreatePlaylistArgs) => { return ( apiController( @@ -490,18 +363,22 @@ export const controller = { deletePlaylist, getAlbumArtistDetail, getAlbumArtistList, + getAlbumArtistListCount, getAlbumDetail, getAlbumList, + getAlbumListCount, getArtistList, getGenreList, getLyrics, getMusicFolderList, getPlaylistDetail, getPlaylistList, + getPlaylistListCount, getPlaylistSongList, getRandomSongList, getSongDetail, getSongList, + getSongListCount, getTopSongList, getUserList, removeFromPlaylist, diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index dd53aab6d..471a60074 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -1,62 +1,64 @@ +import isElectron from 'is-electron'; +import { z } from 'zod'; +import packageJson from '../../../../package.json'; +import { jfNormalize } from './jellyfin-normalize'; +import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types'; +import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; +import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types'; import { - AuthenticationResponse, - MusicFolderListArgs, - MusicFolderListResponse, - GenreListArgs, + AddToPlaylistArgs, + AddToPlaylistResponse, AlbumArtistDetailArgs, + AlbumArtistDetailResponse, AlbumArtistListArgs, - albumArtistListSortMap, - sortOrderMap, - ArtistListArgs, - artistListSortMap, + AlbumArtistListResponse, AlbumDetailArgs, + AlbumDetailResponse, AlbumListArgs, - albumListSortMap, - TopSongListArgs, - SongListArgs, - songListSortMap, - AddToPlaylistArgs, - RemoveFromPlaylistArgs, - PlaylistDetailArgs, - PlaylistSongListArgs, - PlaylistListArgs, - playlistListSortMap, + AlbumListResponse, + AuthenticationResponse, + ControllerEndpoint, CreatePlaylistArgs, CreatePlaylistResponse, - UpdatePlaylistArgs, - UpdatePlaylistResponse, DeletePlaylistArgs, FavoriteArgs, FavoriteResponse, - ScrobbleArgs, - ScrobbleResponse, + GenreListArgs, GenreListResponse, - AlbumArtistDetailResponse, - AlbumArtistListResponse, - AlbumDetailResponse, - AlbumListResponse, - SongListResponse, - AddToPlaylistResponse, - RemoveFromPlaylistResponse, + LyricsArgs, + LyricsResponse, + MusicFolderListArgs, + MusicFolderListResponse, + PlaylistDetailArgs, PlaylistDetailResponse, + PlaylistListArgs, PlaylistListResponse, + PlaylistSongListArgs, + RandomSongListArgs, + RandomSongListResponse, + RemoveFromPlaylistArgs, + RemoveFromPlaylistResponse, + ScrobbleArgs, + ScrobbleResponse, SearchArgs, SearchResponse, - RandomSongListResponse, - RandomSongListArgs, - LyricsArgs, - LyricsResponse, - genreListSortMap, SongDetailArgs, SongDetailResponse, + SongListArgs, + SongListResponse, + SongListSort, + SortOrder, + TopSongListArgs, + UpdatePlaylistArgs, + UpdatePlaylistResponse, + albumArtistListSortMap, + albumListSortMap, + genreListSortMap, + playlistListSortMap, + songListSortMap, + sortOrderMap, } from '/@/renderer/api/types'; -import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; -import { jfNormalize } from './jellyfin-normalize'; -import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types'; -import packageJson from '../../../../package.json'; -import { z } from 'zod'; -import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types'; -import isElectron from 'is-electron'; +import { sortSongList } from '/@/renderer/api/utils'; const formatCommaDelimitedString = (value: string[]) => { return value.join(','); @@ -244,31 +246,56 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { +const getAlbumArtistListCount = async (args: AlbumArtistListArgs): Promise => { const { query, apiClientProps } = args; const res = await jfApiClient(apiClientProps).getAlbumArtistList({ query: { - Limit: query.limit, + Fields: 'Genres, DateCreated, ExternalUrls, Overview', + ImageTypeLimit: 1, + Limit: 1, ParentId: query.musicFolderId, Recursive: true, - SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName', + SearchTerm: query.searchTerm, + SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'Name,SortName', SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, + StartIndex: 0, + UserId: apiClientProps.server?.userId || undefined, }, }); if (res.status !== 200) { - throw new Error('Failed to get artist list'); + throw new Error('Failed to get album artist list count'); } - return { - items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)), - startIndex: query.startIndex, - totalRecordCount: res.body.TotalRecordCount, - }; + return res.body.TotalRecordCount; }; +// const getArtistList = async (args: ArtistListArgs): Promise => { +// const { query, apiClientProps } = args; + +// const res = await jfApiClient(apiClientProps).getAlbumArtistList({ +// query: { +// Limit: query.limit, +// ParentId: query.musicFolderId, +// Recursive: true, +// SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName', +// SortOrder: sortOrderMap.jellyfin[query.sortOrder], +// StartIndex: query.startIndex, +// }, +// }); + +// if (res.status !== 200) { +// throw new Error('Failed to get artist list'); +// } + +// return { +// items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)), +// startIndex: query.startIndex, +// totalRecordCount: res.body.TotalRecordCount, +// }; +// }; + const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { const { query, apiClientProps } = args; @@ -333,6 +360,7 @@ const getAlbumList = async (args: AlbumListArgs): Promise => AlbumArtistIds: query.artistIds ? formatCommaDelimitedString(query.artistIds) : undefined, + ContributingArtistIds: query.isCompilation ? query.artistIds?.[0] : undefined, IncludeItemTypes: 'MusicAlbum', Limit: query.limit, ParentId: query.musicFolderId, @@ -357,6 +385,55 @@ const getAlbumList = async (args: AlbumListArgs): Promise => }; }; +const getAlbumListCount = async (args: AlbumListArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const yearsGroup = []; + if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) { + for ( + let i = Number(query._custom?.jellyfin?.minYear); + i <= Number(query._custom?.jellyfin?.maxYear); + i += 1 + ) { + yearsGroup.push(String(i)); + } + } + + const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined; + + const res = await jfApiClient(apiClientProps).getAlbumList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + AlbumArtistIds: query.artistIds + ? formatCommaDelimitedString(query.artistIds) + : undefined, + ContributingArtistIds: query.isCompilation ? query.artistIds?.[0] : undefined, + IncludeItemTypes: 'MusicAlbum', + Limit: 1, + ParentId: query.musicFolderId, + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: 0, + ...query._custom?.jellyfin, + Years: yearsFilter, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list count'); + } + + return res.body.TotalRecordCount; +}; + const getTopSongList = async (args: TopSongListArgs): Promise => { const { apiClientProps, query } = args; @@ -384,8 +461,11 @@ const getTopSongList = async (args: TopSongListArgs): Promise throw new Error('Failed to get top song list'); } + const songs = res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')); + const songsByPlayCount = sortSongList(songs, SongListSort.PLAY_COUNT, SortOrder.DESC); + return { - items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + items: songsByPlayCount, startIndex: 0, totalRecordCount: res.body.TotalRecordCount, }; @@ -449,6 +529,58 @@ const getSongList = async (args: SongListArgs): Promise => { }; }; +const getSongListCount = async (args: SongListArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const yearsGroup = []; + if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) { + for ( + let i = Number(query._custom?.jellyfin?.minYear); + i <= Number(query._custom?.jellyfin?.maxYear); + i += 1 + ) { + yearsGroup.push(String(i)); + } + } + + const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined; + const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined; + const artistIdsFilter = query.artistIds + ? formatCommaDelimitedString(query.artistIds) + : undefined; + + const res = await jfApiClient(apiClientProps).getSongList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + AlbumIds: albumIdsFilter, + ArtistIds: artistIdsFilter, + Fields: 'Genres, DateCreated, MediaSources, ParentId', + IncludeItemTypes: 'Audio', + Limit: 1, + ParentId: query.musicFolderId, + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: 0, + ...query._custom?.jellyfin, + Years: yearsFilter, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list count'); + } + + return res.body.TotalRecordCount; +}; + const addToPlaylist = async (args: AddToPlaylistArgs): Promise => { const { query, body, apiClientProps } = args; @@ -535,7 +667,6 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise jfNormalize.song(item, apiClientProps.server, '')), - startIndex: query.startIndex, + startIndex: 0, totalRecordCount: res.body.TotalRecordCount, }; }; @@ -589,6 +720,37 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getPlaylistList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview', + IncludeItemTypes: 'Playlist', + Limit: 1, + MediaTypes: 'Audio', + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: playlistListSortMap.jellyfin[query.sortBy], + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: 0, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist list count'); + } + + return res.body.TotalRecordCount; +}; + const createPlaylist = async (args: CreatePlaylistArgs): Promise => { const { body, apiClientProps } = args; @@ -946,7 +1108,7 @@ const getSongDetail = async (args: SongDetailArgs): Promise return jfNormalize.song(res.body, apiClientProps.server, ''); }; -export const jfController = { +export const JellyfinController: ControllerEndpoint = { addToPlaylist, authenticate, createFavorite, @@ -955,19 +1117,22 @@ export const jfController = { deletePlaylist, getAlbumArtistDetail, getAlbumArtistList, + getAlbumArtistListCount, getAlbumDetail, getAlbumList, - getArtistList, + getAlbumListCount, getGenreList, getLyrics, getMusicFolderList, getPlaylistDetail, getPlaylistList, + getPlaylistListCount, getPlaylistSongList, getRandomSongList, getSongDetail, getSongList, - getTopSongList, + getSongListCount, + getTopSongs: getTopSongList, removeFromPlaylist, scrobble, search, diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 486cb1bc9..76b7de40b 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -39,11 +39,13 @@ import { RemoveFromPlaylistResponse, RemoveFromPlaylistArgs, genreListSortMap, + ControllerEndpoint, } from '../types'; import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api'; import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize'; import { ndType } from '/@/renderer/api/navidrome/navidrome-types'; -import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; +import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api'; +import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; const authenticate = async ( url: string, @@ -129,7 +131,7 @@ const getAlbumArtistDetail = async ( }, }); - const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({ + const artistInfoRes = await subsonicApiClient(apiClientProps).getArtistInfo({ query: { count: 10, id: query.id, @@ -148,15 +150,16 @@ const getAlbumArtistDetail = async ( { ...res.body.data, ...(artistInfoRes.status === 200 && { - similarArtists: artistInfoRes.body.artistInfo.similarArtist, + similarArtists: artistInfoRes.body['subsonic-response'].artistInfo.similarArtist, ...(!res.body.data.largeImageUrl && { - largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl, + largeImageUrl: artistInfoRes.body['subsonic-response'].artistInfo.largeImageUrl, }), ...(!res.body.data.mediumImageUrl && { - largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl, + largeImageUrl: + artistInfoRes.body['subsonic-response'].artistInfo.mediumImageUrl, }), ...(!res.body.data.smallImageUrl && { - largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl, + largeImageUrl: artistInfoRes.body['subsonic-response'].artistInfo.smallImageUrl, }), }), }, @@ -191,6 +194,27 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getAlbumArtistList({ + query: { + _end: 1, + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: albumArtistListSortMap.navidrome[query.sortBy], + _start: 0, + name: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album artist list count'); + } + + return Number(res.body.headers.get('x-total-count') || 0); +}; + const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { const { query, apiClientProps } = args; @@ -230,6 +254,8 @@ const getAlbumList = async (args: AlbumListArgs): Promise => _sort: albumListSortMap.navidrome[query.sortBy], _start: query.startIndex, artist_id: query.artistIds?.[0], + compilation: query.isCompilation, + genre_id: query.genre, name: query.searchTerm, ...query._custom?.navidrome, }, @@ -246,6 +272,30 @@ const getAlbumList = async (args: AlbumListArgs): Promise => }; }; +const getAlbumListCount = async (args: AlbumListArgs): Promise => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getAlbumList({ + query: { + _end: 1, + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: albumListSortMap.navidrome[query.sortBy], + _start: 0, + artist_id: query.artistIds?.[0], + compilation: query.isCompilation, + genre_id: query.genre, + name: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list'); + } + + return Number(res.body.headers.get('x-total-count') || 0); +}; + const getSongList = async (args: SongListArgs): Promise => { const { query, apiClientProps } = args; @@ -275,6 +325,29 @@ const getSongList = async (args: SongListArgs): Promise => { }; }; +const getSongListCount = async (args: SongListArgs): Promise => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getSongList({ + query: { + _end: 1, + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: songListSortMap.navidrome[query.sortBy], + _start: 0, + album_artist_id: query.artistIds, + album_id: query.albumIds, + title: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list count'); + } + + return Number(res.body.headers.get('x-total-count') || 0); +}; + const getSongDetail = async (args: SongDetailArgs): Promise => { const { query, apiClientProps } = args; @@ -298,7 +371,7 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getPlaylistList({ + query: { + _end: 1, + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: query.sortBy + ? playlistListSortMap.navidrome[query.sortBy] + : playlistListSortMap.navidrome.name, + _start: 0, + q: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist list count'); + } + + return Number(res.body.headers.get('x-total-count') || 0); +}; + const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise => { const { query, apiClientProps } = args; @@ -404,12 +502,11 @@ const getPlaylistSongList = async ( id: query.id, }, query: { - _end: query.startIndex + (query.limit || 0), _order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC', _sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : ndType._enum.songList.ID, - _start: query.startIndex, + _start: 0, }, }); @@ -419,7 +516,7 @@ const getPlaylistSongList = async ( return { items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')), - startIndex: query?.startIndex || 0, + startIndex: 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }; @@ -465,22 +562,41 @@ const removeFromPlaylist = async ( return null; }; -export const ndController = { +export const NavidromeController: ControllerEndpoint = { addToPlaylist, authenticate, + clearPlaylist: undefined, + createFavorite: SubsonicController.createFavorite, createPlaylist, + deleteFavorite: SubsonicController.deleteFavorite, deletePlaylist, getAlbumArtistDetail, getAlbumArtistList, + getAlbumArtistListCount, getAlbumDetail, getAlbumList, + getAlbumListCount, + getArtistDetail: undefined, + getArtistInfo: undefined, + getFavoritesList: undefined, + getFolderItemList: undefined, + getFolderList: undefined, + getFolderSongs: undefined, getGenreList, + getMusicFolderList: SubsonicController.getMusicFolderList, getPlaylistDetail, getPlaylistList, + getPlaylistListCount, getPlaylistSongList, + getRandomSongList: SubsonicController.getRandomSongList, getSongDetail, getSongList, + getSongListCount, + getTopSongs: SubsonicController.getTopSongs, getUserList, removeFromPlaylist, + scrobble: SubsonicController.scrobble, + search: SubsonicController.search, + setRating: SubsonicController.setRating, updatePlaylist, }; diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts index 4aafb14e0..d03c0d234 100644 --- a/src/renderer/api/navidrome/navidrome-normalize.ts +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -11,8 +11,8 @@ import { import { ServerListItem, ServerType } from '/@/renderer/types'; import z from 'zod'; import { ndType } from './navidrome-types'; -import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; import { NDGenre } from '/@/renderer/api/navidrome.types'; +import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; const getImageUrl = (args: { url: string | null }) => { const { url } = args; @@ -186,7 +186,9 @@ const normalizeAlbum = ( const normalizeAlbumArtist = ( item: z.infer & { - similarArtists?: z.infer['artistInfo']['similarArtist']; + similarArtists?: z.infer< + typeof SubsonicApi.getArtistInfo2.response + >['subsonic-response']['artistInfo2']['similarArtist']; }, server: ServerListItem | null, ): AlbumArtist => { diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 30433e461..fa6b96b0b 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -49,6 +49,19 @@ export const queryKeys: Record< Record QueryFunctionContext['queryKey']> > = { albumArtists: { + count: (serverId: string, query?: AlbumArtistListQuery) => { + const { pagination, filter } = splitPaginatedQuery(query); + + if (query && pagination) { + return [serverId, 'albumArtists', 'count', filter, pagination] as const; + } + + if (query) { + return [serverId, 'albumArtists', 'count', filter] as const; + } + + return [serverId, 'albumArtists', 'count'] as const; + }, detail: (serverId: string, query?: AlbumArtistDetailQuery) => { if (query) return [serverId, 'albumArtists', 'detail', query] as const; return [serverId, 'albumArtists', 'detail'] as const; @@ -72,23 +85,40 @@ export const queryKeys: Record< }, }, albums: { - detail: (serverId: string, query?: AlbumDetailQuery) => - [serverId, 'albums', 'detail', query] as const, - list: (serverId: string, query?: AlbumListQuery, artistId?: string) => { + count: (serverId: string, query?: AlbumListQuery, artistId?: string) => { const { pagination, filter } = splitPaginatedQuery(query); if (query && pagination && artistId) { - return [serverId, 'albums', 'list', artistId, filter, pagination] as const; + return [serverId, 'albums', 'count', artistId, filter, pagination] as const; } if (query && pagination) { - return [serverId, 'albums', 'list', filter, pagination] as const; + return [serverId, 'albums', 'count', filter, pagination] as const; } if (query && artistId) { - return [serverId, 'albums', 'list', artistId, filter] as const; + return [serverId, 'albums', 'count', artistId, filter] as const; + } + + if (query) { + return [serverId, 'albums', 'count', filter] as const; } + return [serverId, 'albums', 'count'] as const; + }, + detail: (serverId: string, query?: AlbumDetailQuery) => + [serverId, 'albums', 'detail', query] as const, + list: ( + serverId: string, + query?: { + artistIds?: string[]; + maxYear?: number; + minYear?: number; + searchTerm?: string; + }, + ) => { + const { filter } = splitPaginatedQuery(query); + if (query) { return [serverId, 'albums', 'list', filter] as const; } @@ -207,6 +237,19 @@ export const queryKeys: Record< root: (serverId: string) => [serverId] as const, }, songs: { + count: (serverId: string, query?: AlbumArtistListQuery) => { + const { pagination, filter } = splitPaginatedQuery(query); + + if (query && pagination) { + return [serverId, 'songs', 'count', filter, pagination] as const; + } + + if (query) { + return [serverId, 'songs', 'count', filter] as const; + } + + return [serverId, 'songs', 'count'] as const; + }, detail: (serverId: string, query?: SongDetailQuery) => { if (query) return [serverId, 'songs', 'detail', query] as const; return [serverId, 'songs', 'detail'] as const; diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 5a620f19a..4d2eb1036 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -1,93 +1,426 @@ import { initClient, initContract } from '@ts-rest/core'; -import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios'; +import axios, { AxiosError, AxiosResponse, Method, isAxiosError } from 'axios'; import omitBy from 'lodash/omitBy'; import qs from 'qs'; -import { z } from 'zod'; -import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; +import i18n from '/@/i18n/i18n'; +import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; import { ServerListItem } from '/@/renderer/api/types'; import { toast } from '/@/renderer/components/toast/index'; -import i18n from '/@/i18n/i18n'; const c = initContract(); export const contract = c.router({ - authenticate: { + changePassword: { method: 'GET', - path: 'ping.view', - query: ssType._parameters.authenticate, + path: 'changePassword.view', + query: SubsonicApi.changePassword.parameters, responses: { - 200: ssType._response.authenticate, + 200: SubsonicApi.changePassword.response, }, }, - createFavorite: { + createInternetRadioStation: { method: 'GET', - path: 'star.view', - query: ssType._parameters.createFavorite, + path: 'createInternetRadioStation.view', + query: SubsonicApi.createInternetRadioStation.parameters, + responses: { + 200: SubsonicApi.createInternetRadioStation.response, + }, + }, + createPlaylist: { + method: 'GET', + path: 'createPlaylist.view', + query: SubsonicApi.createPlaylist.parameters, + responses: { + 200: SubsonicApi.createPlaylist.response, + }, + }, + createShare: { + method: 'GET', + path: 'createShare.view', + query: SubsonicApi.createShare.parameters, + responses: { + 200: SubsonicApi.createShare.response, + }, + }, + createUser: { + method: 'GET', + path: 'createUser.view', + query: SubsonicApi.createUser.parameters, + responses: { + 200: SubsonicApi.createUser.response, + }, + }, + deleteInternetRadioStation: { + method: 'GET', + path: 'deleteInternetRadioStation.view', + query: SubsonicApi.deleteInternetRadioStation.parameters, + responses: { + 200: SubsonicApi.deleteInternetRadioStation.response, + }, + }, + deletePlaylist: { + method: 'GET', + path: 'deletePlaylist.view', + query: SubsonicApi.deletePlaylist.parameters, + responses: { + 200: SubsonicApi.deletePlaylist.response, + }, + }, + deleteShare: { + method: 'GET', + path: 'deleteShare.view', + query: SubsonicApi.deleteShare.parameters, + responses: { + 200: SubsonicApi.deleteShare.response, + }, + }, + deleteUser: { + method: 'GET', + path: 'deleteUser.view', + query: SubsonicApi.deleteUser.parameters, + responses: { + 200: SubsonicApi.deleteUser.response, + }, + }, + getAlbum: { + method: 'GET', + path: 'getAlbum.view', + query: SubsonicApi.getAlbum.parameters, + responses: { + 200: SubsonicApi.getAlbum.response, + }, + }, + getAlbumInfo: { + method: 'GET', + path: 'getAlbumInfo.view', + query: SubsonicApi.getAlbumInfo.parameters, + responses: { + 200: SubsonicApi.getAlbumInfo.response, + }, + }, + getAlbumInfo2: { + method: 'GET', + path: 'getAlbumInfo2.view', + query: SubsonicApi.getAlbumInfo2.parameters, + responses: { + 200: SubsonicApi.getAlbumInfo2.response, + }, + }, + getAlbumList: { + method: 'GET', + path: 'getAlbumList.view', + query: SubsonicApi.getAlbumList.parameters, + responses: { + 200: SubsonicApi.getAlbumList.response, + }, + }, + getAlbumList2: { + method: 'GET', + path: 'getAlbumList2.view', + query: SubsonicApi.getAlbumList2.parameters, + responses: { + 200: SubsonicApi.getAlbumList2.response, + }, + }, + getArtist: { + method: 'GET', + path: 'getArtist.view', + query: SubsonicApi.getArtist.parameters, responses: { - 200: ssType._response.createFavorite, + 200: SubsonicApi.getArtist.response, }, }, getArtistInfo: { method: 'GET', path: 'getArtistInfo.view', - query: ssType._parameters.artistInfo, + query: SubsonicApi.getArtistInfo.parameters, + responses: { + 200: SubsonicApi.getArtistInfo.response, + }, + }, + getArtistInfo2: { + method: 'GET', + path: 'getArtistInfo2.view', + query: SubsonicApi.getArtistInfo2.parameters, + responses: { + 200: SubsonicApi.getArtistInfo2.response, + }, + }, + getArtists: { + method: 'GET', + path: 'getArtists.view', + query: SubsonicApi.getArtists.parameters, + responses: { + 200: SubsonicApi.getArtists.response, + }, + }, + getGenres: { + method: 'GET', + path: 'getGenres.view', + query: SubsonicApi.getGenres.parameters, responses: { - 200: ssType._response.artistInfo, + 200: SubsonicApi.getGenres.response, }, }, - getMusicFolderList: { + getIndexes: { + method: 'GET', + path: 'getIndexes.view', + query: SubsonicApi.getIndexes.parameters, + responses: { + 200: SubsonicApi.getIndexes.response, + }, + }, + getInternetRadioStations: { + method: 'GET', + path: 'getInternetRadioStations.view', + query: SubsonicApi.getInternetRadioStations.parameters, + responses: { + 200: SubsonicApi.getInternetRadioStations.response, + }, + }, + getLicense: { + method: 'GET', + path: 'getLicense.view', + query: SubsonicApi.getLicense.parameters, + responses: { + 200: SubsonicApi.getLicense.response, + }, + }, + getLyrics: { + method: 'GET', + path: 'getLyrics.view', + query: SubsonicApi.getLyrics.parameters, + responses: { + 200: SubsonicApi.getLyrics.response, + }, + }, + getMusicDirectory: { + method: 'GET', + path: 'getMusicDirectory.view', + query: SubsonicApi.getMusicDirectory.parameters, + responses: { + 200: SubsonicApi.getMusicDirectory.response, + }, + }, + getMusicFolders: { method: 'GET', path: 'getMusicFolders.view', responses: { - 200: ssType._response.musicFolderList, + 200: SubsonicApi.getMusicFolders.response, }, }, - getRandomSongList: { + getNowPlaying: { + method: 'GET', + path: 'getNowPlaying.view', + query: SubsonicApi.getNowPlaying.parameters, + responses: { + 200: SubsonicApi.getNowPlaying.response, + }, + }, + getOpenSubsonicExtensions: { + method: 'GET', + path: 'getOpenSubsonicExtensions.view', + query: SubsonicApi.getOpenSubsonicExtensions.parameters, + responses: { + 200: SubsonicApi.getOpenSubsonicExtensions.response, + }, + }, + getPlaylist: { + method: 'GET', + path: 'getPlaylist.view', + query: SubsonicApi.getPlaylist.parameters, + responses: { + 200: SubsonicApi.getPlaylist.response, + }, + }, + getPlaylists: { + method: 'GET', + path: 'getPlaylists.view', + query: SubsonicApi.getPlaylists.parameters, + responses: { + 200: SubsonicApi.getPlaylists.response, + }, + }, + getRandomSongs: { method: 'GET', path: 'getRandomSongs.view', - query: ssType._parameters.randomSongList, + query: SubsonicApi.getRandomSongs.parameters, + responses: { + 200: SubsonicApi.getRandomSongs.response, + }, + }, + getScanStatus: { + method: 'GET', + path: 'getScanStatus.view', responses: { - 200: ssType._response.randomSongList, + 200: SubsonicApi.getScanStatus.response, }, }, - getTopSongsList: { + getShares: { + method: 'GET', + path: 'getShares.view', + query: SubsonicApi.getShares.parameters, + responses: { + 200: SubsonicApi.getShares.response, + }, + }, + getSimilarSongs: { + method: 'GET', + path: 'getSimilarSongs.view', + query: SubsonicApi.getSimilarSongs.parameters, + responses: { + 200: SubsonicApi.getSimilarSongs.response, + }, + }, + getSimilarSongs2: { + method: 'GET', + path: 'getSimilarSongs2.view', + query: SubsonicApi.getSimilarSongs2.parameters, + responses: { + 200: SubsonicApi.getSimilarSongs2.response, + }, + }, + getSong: { + method: 'GET', + path: 'getSong.view', + query: SubsonicApi.getSong.parameters, + responses: { + 200: SubsonicApi.getSong.response, + }, + }, + getSongsByGenre: { + method: 'GET', + path: 'getSongsByGenre.view', + query: SubsonicApi.getSongsByGenre.parameters, + responses: { + 200: SubsonicApi.getSongsByGenre.response, + }, + }, + getStarred: { + method: 'GET', + path: 'getStarred.view', + query: SubsonicApi.getStarred.parameters, + responses: { + 200: SubsonicApi.getStarred.response, + }, + }, + getStarred2: { + method: 'GET', + path: 'getStarred2.view', + query: SubsonicApi.getStarred2.parameters, + responses: { + 200: SubsonicApi.getStarred2.response, + }, + }, + getTopSongs: { method: 'GET', path: 'getTopSongs.view', - query: ssType._parameters.topSongsList, + query: SubsonicApi.getTopSongs.parameters, responses: { - 200: ssType._response.topSongsList, + 200: SubsonicApi.getTopSongs.response, }, }, - removeFavorite: { + getUser: { method: 'GET', - path: 'unstar.view', - query: ssType._parameters.removeFavorite, + path: 'getUser.view', + query: SubsonicApi.getUser.parameters, + responses: { + 200: SubsonicApi.getUser.response, + }, + }, + getUsers: { + method: 'GET', + path: 'getUsers.view', + query: SubsonicApi.getUsers.parameters, + responses: { + 200: SubsonicApi.getUsers.response, + }, + }, + ping: { + method: 'GET', + path: 'ping.view', + query: SubsonicApi.ping.parameters, responses: { - 200: ssType._response.removeFavorite, + 200: SubsonicApi.ping.response, }, }, scrobble: { method: 'GET', path: 'scrobble.view', - query: ssType._parameters.scrobble, + query: SubsonicApi.scrobble.parameters, responses: { - 200: ssType._response.scrobble, + 200: SubsonicApi.scrobble.response, }, }, search3: { method: 'GET', path: 'search3.view', - query: ssType._parameters.search3, + query: SubsonicApi.search3.parameters, responses: { - 200: ssType._response.search3, + 200: SubsonicApi.search3.response, }, }, setRating: { method: 'GET', path: 'setRating.view', - query: ssType._parameters.setRating, + query: SubsonicApi.setRating.parameters, + responses: { + 200: SubsonicApi.setRating.response, + }, + }, + star: { + method: 'GET', + path: 'star.view', + query: SubsonicApi.star.parameters, + responses: { + 200: SubsonicApi.star.response, + }, + }, + startScan: { + method: 'GET', + path: 'startScan.view', + responses: { + 200: SubsonicApi.startScan.response, + }, + }, + unstar: { + method: 'GET', + path: 'unstar.view', + query: SubsonicApi.unstar.parameters, + responses: { + 200: SubsonicApi.unstar.response, + }, + }, + updateInternetRadioStation: { + method: 'GET', + path: 'updateInternetRadioStation.view', + query: SubsonicApi.updateInternetRadioStation.parameters, + responses: { + 200: SubsonicApi.updateInternetRadioStation.response, + }, + }, + updatePlaylist: { + method: 'GET', + path: 'updatePlaylist.view', + query: SubsonicApi.updatePlaylist.parameters, responses: { - 200: ssType._response.setRating, + 200: SubsonicApi.updatePlaylist.response, + }, + }, + updateShare: { + method: 'GET', + path: 'updateShare.view', + query: SubsonicApi.updateShare.parameters, + responses: { + 200: SubsonicApi.updateShare.response, + }, + }, + updateUser: { + method: 'GET', + path: 'updateUser.view', + query: SubsonicApi.updateUser.parameters, + responses: { + 200: SubsonicApi.updateUser.response, }, }, }); @@ -102,14 +435,21 @@ axiosClient.interceptors.response.use( (response) => { const data = response.data; - if (data['subsonic-response'].status !== 'ok') { + // Ping endpoint returns a string + if (typeof data === 'string') { + return response; + } + + if (data['subsonic-response']?.status !== 'ok') { // Suppress code related to non-linked lastfm or spotify from Navidrome - if (data['subsonic-response'].error.code !== 0) { + if (data['subsonic-response']?.error.code !== 0) { toast.error({ - message: data['subsonic-response'].error.message, + message: data['subsonic-response']?.error.message, title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string, }); } + + return Promise.reject(data['subsonic-response']?.error); } return response; @@ -131,7 +471,7 @@ const parsePath = (fullPath: string) => { }; }; -export const ssApiClient = (args: { +export const subsonicApiClient = (args: { server: ServerListItem | null; signal?: AbortSignal; url?: string; @@ -162,9 +502,7 @@ export const ssApiClient = (args: { } try { - const result = await axiosClient.request< - z.infer - >({ + const result = await axiosClient.request({ data: body, headers, method: method as Method, @@ -180,9 +518,9 @@ export const ssApiClient = (args: { }); return { - body: result.data['subsonic-response'], - headers: result.headers as any, - status: result.status, + body: result?.data, + headers: result?.headers as any, + status: result?.status, }; } catch (e: Error | AxiosError | any) { if (isAxiosError(e)) { diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 32c0de170..a95a40baa 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1,27 +1,20 @@ +import dayjs from 'dayjs'; +import filter from 'lodash/filter'; +import orderBy from 'lodash/orderBy'; import md5 from 'md5'; -import { z } from 'zod'; -import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; -import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize'; -import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; +import { fsLog } from '/@/logger'; +import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api'; +import { subsonicNormalize } from '/@/renderer/api/subsonic/subsonic-normalize'; +import { AlbumListSortType, SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; import { - ArtistInfoArgs, + AlbumListSort, AuthenticationResponse, - FavoriteArgs, - FavoriteResponse, + ControllerEndpoint, + GenreListSort, LibraryItem, - MusicFolderListArgs, - MusicFolderListResponse, - SetRatingArgs, - RatingResponse, - ScrobbleArgs, - ScrobbleResponse, - SongListResponse, - TopSongListArgs, - SearchArgs, - SearchResponse, - RandomSongListResponse, - RandomSongListArgs, + PlaylistListSort, } from '/@/renderer/api/types'; +import { sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/renderer/api/utils'; import { randomString } from '/@/renderer/utils'; const authenticate = async ( @@ -59,7 +52,7 @@ const authenticate = async ( }; } - await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({ + await subsonicApiClient({ server: null, url: cleanServerUrl }).ping({ query: { c: 'Feishin', f: 'json', @@ -75,246 +68,1218 @@ const authenticate = async ( }; }; -const getMusicFolderList = async (args: MusicFolderListArgs): Promise => { - const { apiClientProps } = args; +export const SubsonicController: ControllerEndpoint = { + addToPlaylist: async (args) => { + const { body, query, apiClientProps } = args; - const res = await ssApiClient(apiClientProps).getMusicFolderList({}); + const res = await subsonicApiClient(apiClientProps).updatePlaylist({ + query: { + playlistId: query.id, + songIdToAdd: body.songId, + }, + }); - if (res.status !== 200) { - throw new Error('Failed to get music folder list'); - } + if (res.status !== 200) { + fsLog.error('Failed to add to playlist'); + throw new Error('Failed to add to playlist'); + } - return { - items: res.body.musicFolders.musicFolder, - startIndex: 0, - totalRecordCount: res.body.musicFolders.musicFolder.length, - }; -}; + return null; + }, + authenticate: async (url, body) => { + const res = await authenticate(url, body); + return res; + }, + createFavorite: async (args) => { + const { query, apiClientProps } = args; -// export const getAlbumArtistDetail = async ( -// args: AlbumArtistDetailArgs, -// ): Promise => { -// const { server, signal, query } = args; -// const defaultParams = getDefaultParams(server); - -// const searchParams: SSAlbumArtistDetailParams = { -// id: query.id, -// ...defaultParams, -// }; - -// const data = await api -// .get('/getArtist.view', { -// prefixUrl: server?.url, -// searchParams, -// signal, -// }) -// .json(); - -// return data.artist; -// }; - -// const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { -// const { signal, server, query } = args; -// const defaultParams = getDefaultParams(server); - -// const searchParams: SSAlbumArtistListParams = { -// musicFolderId: query.musicFolderId, -// ...defaultParams, -// }; - -// const data = await api -// .get('rest/getArtists.view', { -// prefixUrl: server?.url, -// searchParams, -// signal, -// }) -// .json(); - -// const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist); - -// return { -// items: artists, -// startIndex: query.startIndex, -// totalRecordCount: null, -// }; -// }; - -// const getGenreList = async (args: GenreListArgs): Promise => { -// const { server, signal } = args; -// const defaultParams = getDefaultParams(server); - -// const data = await api -// .get('rest/getGenres.view', { -// prefixUrl: server?.url, -// searchParams: defaultParams, -// signal, -// }) -// .json(); - -// return data.genres.genre; -// }; - -// const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { -// const { server, query, signal } = args; -// const defaultParams = getDefaultParams(server); - -// const searchParams = { -// id: query.id, -// ...defaultParams, -// }; - -// const data = await api -// .get('rest/getAlbum.view', { -// prefixUrl: server?.url, -// searchParams: parseSearchParams(searchParams), -// signal, -// }) -// .json(); - -// const { song: songs, ...dataWithoutSong } = data.album; -// return { ...dataWithoutSong, songs }; -// }; - -// const getAlbumList = async (args: AlbumListArgs): Promise => { -// const { server, query, signal } = args; -// const defaultParams = getDefaultParams(server); - -// const searchParams = { -// ...defaultParams, -// }; -// const data = await api -// .get('rest/getAlbumList2.view', { -// prefixUrl: server?.url, -// searchParams: parseSearchParams(searchParams), -// signal, -// }) -// .json(); - -// return { -// items: data.albumList2.album, -// startIndex: query.startIndex, -// totalRecordCount: null, -// }; -// }; - -const createFavorite = async (args: FavoriteArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ssApiClient(apiClientProps).createFavorite({ - query: { - albumId: query.type === LibraryItem.ALBUM ? query.id : undefined, - artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined, - id: query.type === LibraryItem.SONG ? query.id : undefined, - }, - }); + const res = await subsonicApiClient(apiClientProps).star({ + query: { + albumId: query.type === LibraryItem.ALBUM ? query.id : undefined, + artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined, + id: query.type === LibraryItem.SONG ? query.id : undefined, + }, + }); - if (res.status !== 200) { - throw new Error('Failed to create favorite'); - } + if (res.status !== 200) { + fsLog.error('Failed to create favorite'); + throw new Error('Failed to create favorite'); + } - return null; -}; + return null; + }, + createPlaylist: async (args) => { + const { body, apiClientProps } = args; -const removeFavorite = async (args: FavoriteArgs): Promise => { - const { query, apiClientProps } = args; + const res = await subsonicApiClient(apiClientProps).createPlaylist({ + query: { + name: body.name, + }, + }); - const res = await ssApiClient(apiClientProps).removeFavorite({ - query: { - albumId: query.type === LibraryItem.ALBUM ? query.id : undefined, - artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined, - id: query.type === LibraryItem.SONG ? query.id : undefined, - }, - }); + if (res.status !== 200) { + fsLog.error('Failed to create playlist'); + throw new Error('Failed to create playlist'); + } - if (res.status !== 200) { - throw new Error('Failed to delete favorite'); - } + return { + id: res.body['subsonic-response'].playlist.id, + name: res.body['subsonic-response'].playlist.name, + }; + }, + deleteFavorite: async (args) => { + const { query, apiClientProps } = args; - return null; -}; + const res = await subsonicApiClient(apiClientProps).unstar({ + query: { + albumId: query.type === LibraryItem.ALBUM ? query.id : undefined, + artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined, + id: query.type === LibraryItem.SONG ? query.id : undefined, + }, + }); -const setRating = async (args: SetRatingArgs): Promise => { - const { query, apiClientProps } = args; + if (res.status !== 200) { + fsLog.error('Failed to delete favorite'); + throw new Error('Failed to delete favorite'); + } - const itemIds = query.item.map((item) => item.id); + return null; + }, + deletePlaylist: async (args) => { + const { query, apiClientProps } = args; - for (const id of itemIds) { - await ssApiClient(apiClientProps).setRating({ + const res = await subsonicApiClient(apiClientProps).deletePlaylist({ query: { - id, - rating: query.rating, + id: query.id, }, }); - } - return null; -}; + if (res.status !== 200) { + fsLog.error('Failed to delete playlist'); + throw new Error('Failed to delete playlist'); + } -const getTopSongList = async (args: TopSongListArgs): Promise => { - const { query, apiClientProps } = args; + return null; + }, + getAlbumArtistDetail: async (args) => { + const { query, apiClientProps } = args; - const res = await ssApiClient(apiClientProps).getTopSongsList({ - query: { - artist: query.artist, - count: query.limit, - }, - }); + const artistInfoRes = await subsonicApiClient(apiClientProps).getArtistInfo({ + query: { + id: query.id, + }, + }); - if (res.status !== 200) { - throw new Error('Failed to get top songs'); - } + const res = await subsonicApiClient(apiClientProps).getArtist({ + query: { + id: query.id, + }, + }); - return { - items: - res.body.topSongs?.song?.map((song) => - ssNormalize.song(song, apiClientProps.server, ''), - ) || [], - startIndex: 0, - totalRecordCount: res.body.topSongs?.song?.length || 0, - }; -}; + if (res.status !== 200) { + fsLog.error('Failed to get album artist detail'); + throw new Error('Failed to get album artist detail'); + } -const getArtistInfo = async ( - args: ArtistInfoArgs, -): Promise> => { - const { query, apiClientProps } = args; + const artist = res.body['subsonic-response'].artist; - const res = await ssApiClient(apiClientProps).getArtistInfo({ - query: { - count: query.limit, - id: query.artistId, - }, - }); + let artistInfo; + if (artistInfoRes.status === 200) { + artistInfo = artistInfoRes.body['subsonic-response'].artistInfo; + fsLog.warn('Failed to get artist info'); + } - if (res.status !== 200) { - throw new Error('Failed to get artist info'); - } + return { + ...subsonicNormalize.albumArtist(artist, apiClientProps.server, 300), + albums: artist.album.map((album) => + subsonicNormalize.album(album, apiClientProps.server), + ), + similarArtists: + artistInfo?.similarArtist?.map((artist) => + subsonicNormalize.albumArtist(artist, apiClientProps.server, 300), + ) || null, + }; + }, + getAlbumArtistList: async (args) => { + const { query, apiClientProps } = args; - return res.body; -}; + const res = await subsonicApiClient(apiClientProps).getArtists({ + query: { + musicFolderId: query.musicFolderId, + }, + }); -const scrobble = async (args: ScrobbleArgs): Promise => { - const { query, apiClientProps } = args; + if (res.status !== 200) { + fsLog.error('Failed to get album artist list'); + throw new Error('Failed to get album artist list'); + } - const res = await ssApiClient(apiClientProps).scrobble({ - query: { - id: query.id, - submission: query.submission, - }, - }); + const artists = (res.body['subsonic-response'].artists?.index || []).flatMap( + (index) => index.artist, + ); - if (res.status !== 200) { - throw new Error('Failed to scrobble'); - } + let results = artists.map((artist) => + subsonicNormalize.albumArtist(artist, apiClientProps.server, 300), + ); - return null; -}; + if (query.searchTerm) { + const searchResults = filter(results, (artist) => { + return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); -const search3 = async (args: SearchArgs): Promise => { - const { query, apiClientProps } = args; + results = searchResults; + } - const res = await ssApiClient(apiClientProps).search3({ - query: { + if (query.sortBy) { + results = sortAlbumArtistList(results, query.sortBy, query.sortOrder); + } + + return { + items: results, + startIndex: query.startIndex, + totalRecordCount: results?.length || 0, + }; + }, + getAlbumArtistListCount: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getArtists({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album artist list count'); + throw new Error('Failed to get album artist list count'); + } + + const artists = (res.body['subsonic-response'].artists?.index || []).flatMap( + (index) => index.artist, + ); + + let results = artists; + let totalRecordCount = artists.length; + + if (query.searchTerm) { + const searchResults = filter(results, (artist) => { + return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + totalRecordCount = searchResults.length; + } + + return totalRecordCount; + }, + getAlbumDetail: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getAlbum({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album detail', { + context: { id: query.id }, + }); + throw new Error('Failed to get album detail'); + } + + return subsonicNormalize.album(res.body['subsonic-response'].album, apiClientProps.server); + }, + getAlbumList: async (args) => { + const { query, apiClientProps } = args; + + if (query.searchTerm) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: query.limit, + albumOffset: query.startIndex, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 0, + songOffset: 0, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list'); + throw new Error('Failed to get album list'); + } + + const results = + res.body['subsonic-response'].searchResult3.album?.map((album) => + subsonicNormalize.album(album, apiClientProps.server), + ) || []; + + return { + items: results, + startIndex: query.startIndex, + totalRecordCount: null, + }; + } + + const sortType: Record = { + [AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM, + [AlbumListSort.ALBUM_ARTIST]: + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_ARTIST, + [AlbumListSort.PLAY_COUNT]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.FREQUENT, + [AlbumListSort.RECENTLY_ADDED]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.NEWEST, + [AlbumListSort.FAVORITED]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.STARRED, + [AlbumListSort.YEAR]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RECENT, + [AlbumListSort.NAME]: + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME, + [AlbumListSort.COMMUNITY_RATING]: undefined, + [AlbumListSort.DURATION]: undefined, + [AlbumListSort.CRITIC_RATING]: undefined, + [AlbumListSort.RATING]: undefined, + [AlbumListSort.ARTIST]: undefined, + [AlbumListSort.RECENTLY_PLAYED]: undefined, + [AlbumListSort.RELEASE_DATE]: undefined, + [AlbumListSort.SONG_COUNT]: undefined, + }; + + let type = + sortType[query.sortBy] ?? + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME; + + if (query.artistIds) { + const promises = []; + + for (const artistId of query.artistIds) { + promises.push( + subsonicApiClient(apiClientProps).getArtist({ + query: { + id: artistId, + }, + }), + ); + } + + const artistResult = await Promise.all(promises); + + const albums = artistResult.flatMap((artist) => { + if (artist.status !== 200) { + fsLog.warn('Failed to get artist detail', { context: { artist } }); + return []; + } + + return artist.body['subsonic-response'].artist.album; + }); + + return { + items: albums.map((album) => subsonicNormalize.album(album, apiClientProps.server)), + startIndex: 0, + totalRecordCount: albums.length, + }; + } + + if (query.isFavorite) { + const res = await subsonicApiClient(apiClientProps).getStarred({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list'); + throw new Error('Failed to get album list'); + } + + const results = + res.body['subsonic-response'].starred.album?.map((album) => + subsonicNormalize.album(album, apiClientProps.server), + ) || []; + + return { + items: sortAlbumList(results, query.sortBy, query.sortOrder), + startIndex: 0, + totalRecordCount: res.body['subsonic-response'].starred.album?.length || 0, + }; + } + + if (query.genre) { + type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_GENRE; + } + + if (query.minYear || query.maxYear) { + type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_YEAR; + } + + let fromYear; + let toYear; + + if (query.minYear) { + fromYear = query.minYear; + toYear = dayjs().year(); + } + + if (query.maxYear) { + toYear = query.maxYear; + + if (!query.minYear) { + fromYear = 0; + } + } + + const res = await subsonicApiClient(apiClientProps).getAlbumList2({ + query: { + fromYear, + genre: query.genre, + musicFolderId: query.musicFolderId, + offset: query.startIndex, + size: query.limit, + toYear, + type, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list'); + throw new Error('Failed to get album list'); + } + + return { + items: + res.body['subsonic-response'].albumList2.album?.map((album) => + subsonicNormalize.album(album, apiClientProps.server, 300), + ) || [], + startIndex: query.startIndex, + totalRecordCount: null, + }; + }, + getAlbumListCount: async (args) => { + const { query, apiClientProps } = args; + + if (query.searchTerm) { + let fetchNextPage = true; + let startIndex = 0; + let totalRecordCount = 0; + + while (fetchNextPage) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: 500, + albumOffset: startIndex, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 0, + songOffset: 0, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list count'); + throw new Error('Failed to get album list count'); + } + + const albumCount = res.body['subsonic-response'].searchResult3.album?.length; + + totalRecordCount += albumCount; + startIndex += albumCount; + + // The max limit size for Subsonic is 500 + fetchNextPage = albumCount === 500; + } + + return totalRecordCount; + } + + const sortType: Record = { + [AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM, + [AlbumListSort.ALBUM_ARTIST]: + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_ARTIST, + [AlbumListSort.PLAY_COUNT]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.FREQUENT, + [AlbumListSort.RECENTLY_ADDED]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.NEWEST, + [AlbumListSort.FAVORITED]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.STARRED, + [AlbumListSort.YEAR]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RECENT, + [AlbumListSort.NAME]: + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME, + [AlbumListSort.COMMUNITY_RATING]: undefined, + [AlbumListSort.DURATION]: undefined, + [AlbumListSort.CRITIC_RATING]: undefined, + [AlbumListSort.RATING]: undefined, + [AlbumListSort.ARTIST]: undefined, + [AlbumListSort.RECENTLY_PLAYED]: undefined, + [AlbumListSort.RELEASE_DATE]: undefined, + [AlbumListSort.SONG_COUNT]: undefined, + }; + + if (query.isFavorite) { + const res = await subsonicApiClient(apiClientProps).getStarred({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list'); + throw new Error('Failed to get album list'); + } + + return res.body['subsonic-response'].starred.album?.length || 0; + } + + let type = + sortType[query.sortBy] ?? + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME; + + let fetchNextPage = true; + let startIndex = 0; + let totalRecordCount = 0; + + if (query.genre) { + type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_GENRE; + } + + if (query.minYear || query.maxYear) { + type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_YEAR; + } + + let fromYear; + let toYear; + + if (query.minYear) { + fromYear = query.minYear; + toYear = dayjs().year(); + } + + if (query.maxYear) { + toYear = query.maxYear; + + if (!query.minYear) { + fromYear = 0; + } + } + + while (fetchNextPage) { + const res = await subsonicApiClient(apiClientProps).getAlbumList2({ + query: { + fromYear, + genre: query.genre, + musicFolderId: query.musicFolderId, + offset: startIndex, + size: 500, + toYear, + type, + }, + }); + + const headers = res.headers; + + // Navidrome returns the total count in the header + if (headers.get('x-total-count')) { + fetchNextPage = false; + totalRecordCount = Number(headers.get('x-total-count')); + break; + } + + if (res.status !== 200) { + fsLog.error('Failed to get album list count'); + throw new Error('Failed to get album list count'); + } + + const albumCount = res.body['subsonic-response'].albumList2.album.length; + + totalRecordCount += albumCount; + startIndex += albumCount; + + // The max limit size for Subsonic is 500 + fetchNextPage = albumCount === 500; + } + + return totalRecordCount; + }, + getAlbumSongList: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getAlbum({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album song list'); + throw new Error('Failed to get album song list'); + } + + return { + items: res.body['subsonic-response'].album.song.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ), + startIndex: 0, + totalRecordCount: res.body['subsonic-response'].album.song.length, + }; + }, + getArtistInfo: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getArtistInfo({ + query: { + id: query.artistId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get artist info', { + context: { id: query.artistId }, + }); + throw new Error('Failed to get artist info'); + } + + return res.body['subsonic-response'].artistInfo; + }, + getGenreList: async (args) => { + const { query, apiClientProps } = args; + const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; + + const res = await subsonicApiClient(apiClientProps).getGenres({}); + + if (res.status !== 200) { + fsLog.error('Failed to get genre list'); + throw new Error('Failed to get genre list'); + } + + let results = res.body['subsonic-response'].genres.genre; + + if (query.searchTerm) { + const searchResults = filter(results, (genre) => + genre.value.toLowerCase().includes(query.searchTerm!.toLowerCase()), + ); + + results = searchResults; + } + + switch (query.sortBy) { + case GenreListSort.NAME: + results = orderBy(results, [(v) => v.value.toLowerCase()], [sortOrder]); + break; + case GenreListSort.ALBUM_COUNT: + results = orderBy(results, ['albumCount'], [sortOrder]); + break; + case GenreListSort.SONG_COUNT: + results = orderBy(results, ['songCount'], [sortOrder]); + break; + default: + break; + } + + const genres = results.map(subsonicNormalize.genre); + + return { + items: genres, + startIndex: 0, + totalRecordCount: genres.length, + }; + }, + getMusicFolderList: async (args) => { + const { apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getMusicFolders({}); + + if (res.status !== 200) { + fsLog.error('Failed to get music folder list'); + throw new Error('Failed to get music folder list'); + } + + return { + items: res.body['subsonic-response'].musicFolders.musicFolder.map( + subsonicNormalize.musicFolder, + ), + startIndex: 0, + totalRecordCount: res.body['subsonic-response'].musicFolders.musicFolder.length, + }; + }, + getPlaylistDetail: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getPlaylist({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get playlist detail'); + throw new Error('Failed to get playlist detail'); + } + + return subsonicNormalize.playlist( + res.body['subsonic-response'].playlist, + apiClientProps.server, + ); + }, + getPlaylistList: async (args) => { + const { query, apiClientProps } = args; + const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; + + const res = await subsonicApiClient(apiClientProps).getPlaylists({}); + + if (res.status !== 200) { + fsLog.error('Failed to get playlist list'); + throw new Error('Failed to get playlist list'); + } + + let results = res.body['subsonic-response'].playlists.playlist; + + if (query.searchTerm) { + const searchResults = filter(results, (playlist) => { + return playlist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + } + + switch (query.sortBy) { + case PlaylistListSort.DURATION: + results = orderBy(results, ['duration'], [sortOrder]); + break; + case PlaylistListSort.NAME: + results = orderBy(results, [(v) => v.name?.toLowerCase()], [sortOrder]); + break; + case PlaylistListSort.OWNER: + results = orderBy(results, [(v) => v.owner?.toLowerCase()], [sortOrder]); + break; + case PlaylistListSort.PUBLIC: + results = orderBy(results, ['public'], [sortOrder]); + break; + case PlaylistListSort.SONG_COUNT: + results = orderBy(results, ['songCount'], [sortOrder]); + break; + case PlaylistListSort.UPDATED_AT: + results = orderBy(results, ['changed'], [sortOrder]); + break; + default: + break; + } + + return { + items: results.map((playlist) => + subsonicNormalize.playlist(playlist, apiClientProps.server), + ), + startIndex: 0, + totalRecordCount: res.body['subsonic-response'].playlists.playlist.length, + }; + }, + getPlaylistListCount: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getPlaylists({}); + + if (res.status !== 200) { + fsLog.error('Failed to get playlist list count'); + throw new Error('Failed to get playlist list count'); + } + + if (query.searchTerm) { + const searchResults = filter( + res.body['subsonic-response'].playlists.playlist, + (playlist) => { + return playlist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }, + ); + + return searchResults.length; + } + + return res.body['subsonic-response'].playlists.playlist.length; + }, + getPlaylistSongList: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getPlaylist({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get playlist song list'); + throw new Error('Failed to get playlist song list'); + } + + let results = + res.body['subsonic-response'].playlist.entry?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ) || []; + + if (query.searchTerm) { + const searchResults = filter(results, (entry) => { + return entry.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + } + + if (query.sortBy) { + results = sortSongList(results, query.sortBy, query.sortOrder); + } + + return { + items: results, + startIndex: 0, + totalRecordCount: results?.length || 0, + }; + }, + getRandomSongList: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getRandomSongs({ + query: { + fromYear: query.minYear, + genre: query.genre, + musicFolderId: query.musicFolderId, + size: query.limit, + toYear: query.maxYear, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get random songs'); + throw new Error('Failed to get random songs'); + } + + return { + items: res.body['subsonic-response'].randomSongs?.song?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ), + startIndex: 0, + totalRecordCount: null, + }; + }, + getSongDetail: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getSong({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song detail'); + throw new Error('Failed to get song detail'); + } + + return subsonicNormalize.song( + res.body['subsonic-response'].song, + apiClientProps.server, + '', + ); + }, + getSongList: async (args) => { + const { query, apiClientProps } = args; + + const fromAlbumPromises = []; + const artistDetailPromises = []; + let results: any[] = []; + + if (query.searchTerm) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: query.limit, + songOffset: query.startIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list'); + throw new Error('Failed to get song list'); + } + + return { + items: + res.body['subsonic-response'].searchResult3?.song?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ) || [], + startIndex: query.startIndex, + totalRecordCount: null, + }; + } + + if (query.genre) { + const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ + query: { + count: query.limit, + genre: query.genre, + musicFolderId: query.musicFolderId, + offset: query.startIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list'); + throw new Error('Failed to get song list'); + } + + return { + items: + res.body['subsonic-response'].songsByGenre.song?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ) || [], + startIndex: 0, + totalRecordCount: null, + }; + } + + if (query.isFavorite) { + const res = await subsonicApiClient(apiClientProps).getStarred({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list'); + throw new Error('Failed to get song list'); + } + + const results = + res.body['subsonic-response'].starred.song?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ) || []; + + return { + items: sortSongList(results, query.sortBy, query.sortOrder), + startIndex: 0, + totalRecordCount: res.body['subsonic-response'].starred.song?.length || 0, + }; + } + + if (query.albumIds || query.artistIds) { + if (query.albumIds) { + for (const albumId of query.albumIds) { + fromAlbumPromises.push( + subsonicApiClient(apiClientProps).getAlbum({ + query: { + id: albumId, + }, + }), + ); + } + } + + if (query.artistIds) { + for (const artistId of query.artistIds) { + artistDetailPromises.push( + subsonicApiClient(apiClientProps).getArtist({ + query: { + id: artistId, + }, + }), + ); + } + + const artistResult = await Promise.all(artistDetailPromises); + + const albums = artistResult.flatMap((artist) => { + if (artist.status !== 200) { + fsLog.warn('Failed to get artist detail', { context: { artist } }); + return []; + } + + return artist.body['subsonic-response'].artist.album; + }); + + const albumIds = albums.map((album) => album.id); + + for (const albumId of albumIds) { + fromAlbumPromises.push( + subsonicApiClient(apiClientProps).getAlbum({ + query: { + id: albumId, + }, + }), + ); + } + } + + if (fromAlbumPromises) { + const albumsResult = await Promise.all(fromAlbumPromises); + + results = albumsResult.flatMap((album) => { + if (album.status !== 200) { + fsLog.warn('Failed to get album detail', { context: { album } }); + return []; + } + + return album.body['subsonic-response'].album.song; + }); + } + + return { + items: results.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ), + startIndex: 0, + totalRecordCount: results.length, + }; + } + + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: query.limit, + songOffset: query.startIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list'); + throw new Error('Failed to get song list'); + } + + return { + items: + res.body['subsonic-response'].searchResult3?.song?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ) || [], + startIndex: 0, + totalRecordCount: null, + }; + }, + getSongListCount: async (args) => { + const { query, apiClientProps } = args; + + let fetchNextPage = true; + let startIndex = 0; + + let fetchNextSection = true; + let sectionIndex = 0; + + if (query.searchTerm) { + let fetchNextPage = true; + let startIndex = 0; + let totalRecordCount = 0; + + while (fetchNextPage) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 500, + songOffset: startIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list count'); + throw new Error('Failed to get song list count'); + } + + const songCount = res.body['subsonic-response'].searchResult3.song?.length; + + totalRecordCount += songCount; + startIndex += songCount; + + // The max limit size for Subsonic is 500 + fetchNextPage = songCount === 500; + } + + return totalRecordCount; + } + + if (query.genre) { + let totalRecordCount = 0; + while (fetchNextSection) { + const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ + query: { + count: 1, + genre: query.genre, + musicFolderId: query.musicFolderId, + offset: sectionIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list count'); + throw new Error('Failed to get song list count'); + } + + const numberOfResults = + res.body['subsonic-response'].songsByGenre.song?.length || 0; + + if (numberOfResults !== 1) { + fetchNextSection = false; + startIndex = sectionIndex === 0 ? 0 : sectionIndex - 5000; + break; + } else { + sectionIndex += 5000; + } + } + + while (fetchNextPage) { + const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ + query: { + count: 500, + genre: query.genre, + musicFolderId: query.musicFolderId, + offset: startIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list count'); + throw new Error('Failed to get song list count'); + } + + const numberOfResults = + res.body['subsonic-response'].songsByGenre.song?.length || 0; + + totalRecordCount = startIndex + numberOfResults; + startIndex += numberOfResults; + + fetchNextPage = numberOfResults === 500; + } + + return totalRecordCount; + } + + if (query.isFavorite) { + const res = await subsonicApiClient(apiClientProps).getStarred({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list'); + throw new Error('Failed to get song list'); + } + + return res.body['subsonic-response'].starred.song?.length || 0; + } + + let totalRecordCount = 0; + + while (fetchNextSection) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 1, + songOffset: sectionIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list count'); + throw new Error('Failed to get song list count'); + } + + const numberOfResults = res.body['subsonic-response'].searchResult3.song?.length || 0; + + // Check each batch of 5000 songs to check for data + sectionIndex += 5000; + fetchNextSection = numberOfResults === 1; + + if (!fetchNextSection) { + // fetchNextBlock will be false on the next loop so we need to subtract 5000 * 2 + startIndex = sectionIndex - 10000; + } + } + + while (fetchNextPage) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 500, + songOffset: startIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list count'); + throw new Error('Failed to get song list count'); + } + + const numberOfResults = res.body['subsonic-response'].searchResult3.song?.length || 0; + + totalRecordCount = startIndex + numberOfResults; + startIndex += numberOfResults; + + // The max limit size for Subsonic is 500 + fetchNextPage = numberOfResults === 500; + } + + return totalRecordCount; + }, + getTopSongs: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getTopSongs({ + query: { + artist: query.artist, + count: query.limit, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get top songs', { + context: { artist: query.artist }, + }); + throw new Error('Failed to get top songs'); + } + + return { + items: + res.body['subsonic-response'].topSongs?.song?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ) || [], + startIndex: 0, + totalRecordCount: null, + }; + }, + scrobble: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).scrobble({ + query: { + id: query.id, + submission: query.submission, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to scrobble', { + context: { + id: query.id, + }, + }); + throw new Error('Failed to scrobble'); + } + + return null; + }, + search: async (args) => { + const { query, apiClientProps } = args; + + const searchQuery = { albumCount: query.albumLimit, albumOffset: query.albumStartIndex, artistCount: query.albumArtistLimit, @@ -322,61 +1287,64 @@ const search3 = async (args: SearchArgs): Promise => { query: query.query, songCount: query.songLimit, songOffset: query.songStartIndex, - }, - }); + }; - if (res.status !== 200) { - throw new Error('Failed to search'); - } + const res = await subsonicApiClient(apiClientProps).search3({ + query: searchQuery, + }); - return { - albumArtists: res.body.searchResult3?.artist?.map((artist) => - ssNormalize.albumArtist(artist, apiClientProps.server), - ), - albums: res.body.searchResult3?.album?.map((album) => - ssNormalize.album(album, apiClientProps.server), - ), - songs: res.body.searchResult3?.song?.map((song) => - ssNormalize.song(song, apiClientProps.server, ''), - ), - }; -}; + if (res.status !== 200) { + fsLog.error('Failed to search', { + context: searchQuery, + }); + throw new Error('Failed to search'); + } -const getRandomSongList = async (args: RandomSongListArgs): Promise => { - const { query, apiClientProps } = args; + return { + albumArtists: res.body['subsonic-response'].searchResult3?.artist?.map((artist) => + subsonicNormalize.albumArtist(artist, apiClientProps.server), + ), + albums: res.body['subsonic-response'].searchResult3?.album?.map((album) => + subsonicNormalize.album(album, apiClientProps.server), + ), + songs: res.body['subsonic-response'].searchResult3?.song?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ), + }; + }, + setRating: async (args) => { + const { query, apiClientProps } = args; - const res = await ssApiClient(apiClientProps).getRandomSongList({ - query: { - fromYear: query.minYear, - genre: query.genre, - musicFolderId: query.musicFolderId, - size: query.limit, - toYear: query.maxYear, - }, - }); + const itemIds = query.item.map((item) => item.id); - if (res.status !== 200) { - throw new Error('Failed to get random songs'); - } + for (const id of itemIds) { + await subsonicApiClient(apiClientProps).setRating({ + query: { + id, + rating: query.rating, + }, + }); + } - return { - items: res.body.randomSongs?.song?.map((song) => - ssNormalize.song(song, apiClientProps.server, ''), - ), - startIndex: 0, - totalRecordCount: res.body.randomSongs?.song?.length || 0, - }; -}; + return null; + }, + updatePlaylist: async (args) => { + const { body, query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).updatePlaylist({ + query: { + comment: body.comment, + name: body.name, + playlistId: query.id, + public: body.public, + }, + }); -export const ssController = { - authenticate, - createFavorite, - getArtistInfo, - getMusicFolderList, - getRandomSongList, - getTopSongList, - removeFavorite, - scrobble, - search3, - setRating, + if (res.status !== 200) { + fsLog.error('Failed to update playlist'); + throw new Error('Failed to update playlist'); + } + + return null; + }, }; diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index 881e7fef2..7100826b2 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -1,7 +1,15 @@ import { nanoid } from 'nanoid'; import { z } from 'zod'; -import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; -import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/types'; +import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; +import { + QueueSong, + LibraryItem, + AlbumArtist, + Album, + Genre, + MusicFolder, + Playlist, +} from '/@/renderer/api/types'; import { ServerListItem, ServerType } from '/@/renderer/types'; const getCoverArtUrl = (args: { @@ -27,16 +35,17 @@ const getCoverArtUrl = (args: { }; const normalizeSong = ( - item: z.infer, + item: z.infer, server: ServerListItem | null, deviceId: string, + size?: number, ): QueueSong => { const imageUrl = getCoverArtUrl({ baseUrl: server?.url, coverArtId: item.coverArt, credential: server?.credential, - size: 100, + size: size || 300, }) || null; const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`; @@ -105,15 +114,18 @@ const normalizeSong = ( }; const normalizeAlbumArtist = ( - item: z.infer, + item: + | z.infer + | z.infer, server: ServerListItem | null, + imageSize?: number, ): AlbumArtist => { const imageUrl = getCoverArtUrl({ baseUrl: server?.url, coverArtId: item.coverArt, credential: server?.credential, - size: 100, + size: imageSize || 100, }) || null; return { @@ -138,15 +150,18 @@ const normalizeAlbumArtist = ( }; const normalizeAlbum = ( - item: z.infer, + item: + | z.infer + | z.infer, server: ServerListItem | null, + size?: number, ): Album => { const imageUrl = getCoverArtUrl({ baseUrl: server?.url, coverArtId: item.coverArt, credential: server?.credential, - size: 300, + size: size || 300, }) || null; return { @@ -156,7 +171,7 @@ const normalizeAlbum = ( artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [], backdropImageUrl: null, createdAt: item.created, - duration: item.duration, + duration: item.duration * 1000, genres: item.genre ? [ { @@ -181,7 +196,10 @@ const normalizeAlbum = ( serverType: ServerType.SUBSONIC, size: null, songCount: item.songCount, - songs: [], + songs: + (item as z.infer).song?.map((song) => + normalizeSong(song, server, ''), + ) || [], uniqueId: nanoid(), updatedAt: item.created, userFavorite: item.starred || false, @@ -189,8 +207,61 @@ const normalizeAlbum = ( }; }; -export const ssNormalize = { +const normalizeGenre = (item: z.infer): Genre => { + return { + albumCount: item.albumCount, + id: item.value, + imageUrl: null, + itemType: LibraryItem.GENRE, + name: item.value, + songCount: item.songCount, + }; +}; + +const normalizeMusicFolder = ( + item: z.infer, +): MusicFolder => { + return { + id: item.id, + name: item.name, + }; +}; + +const normalizePlaylist = ( + item: + | z.infer + | z.infer, + server: ServerListItem | null, +): Playlist => { + return { + description: item.comment || null, + duration: item.duration, + genres: [], + id: item.id, + imagePlaceholderUrl: null, + imageUrl: getCoverArtUrl({ + baseUrl: server?.url, + coverArtId: item.coverArt, + credential: server?.credential, + size: 300, + }), + itemType: LibraryItem.PLAYLIST, + name: item.name, + owner: item.owner, + ownerId: item.owner, + public: item.public, + serverId: server?.id || 'unknown', + serverType: ServerType.SUBSONIC, + size: null, + songCount: item.songCount, + }; +}; + +export const subsonicNormalize = { album: normalizeAlbum, albumArtist: normalizeAlbumArtist, + genre: normalizeGenre, + musicFolder: normalizeMusicFolder, + playlist: normalizePlaylist, song: normalizeSong, }; diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index 3360081b6..20ec25b29 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -1,56 +1,30 @@ import { z } from 'zod'; const baseResponse = z.object({ - 'subsonic-response': z.object({ - status: z.string(), - version: z.string(), - }), -}); - -const authenticate = z.null(); - -const authenticateParameters = z.object({ - c: z.string(), - f: z.string(), - p: z.string().optional(), - s: z.string().optional(), - t: z.string().optional(), - u: z.string(), - v: z.string(), + 'subsonic-response': z + .object({ + status: z.string(), + version: z.string(), + }) + // OpenSubsonic v1.0.0 + .extend({ + openSubsonic: z.boolean().optional(), + serverVersion: z.string().optional(), + type: z.string().optional(), + }), }); -const createFavoriteParameters = z.object({ - albumId: z.array(z.string()).optional(), - artistId: z.array(z.string()).optional(), - id: z.array(z.string()).optional(), -}); - -const createFavorite = z.null(); - -const removeFavoriteParameters = z.object({ - albumId: z.array(z.string()).optional(), - artistId: z.array(z.string()).optional(), - id: z.array(z.string()).optional(), -}); - -const removeFavorite = z.null(); - -const setRatingParameters = z.object({ - id: z.string(), - rating: z.number(), -}); - -const setRating = z.null(); +const baseResponseShape = baseResponse.shape['subsonic-response']; const musicFolder = z.object({ id: z.string(), name: z.string(), }); -const musicFolderList = z.object({ - musicFolders: z.object({ - musicFolder: z.array(musicFolder), - }), +const genre = z.object({ + albumCount: z.number(), + songCount: z.number(), + value: z.string(), }); const song = z.object({ @@ -103,138 +77,887 @@ const album = z.object({ year: z.number().optional(), }); -const albumListParameters = z.object({ - fromYear: z.number().optional(), - genre: z.string().optional(), - musicFolderId: z.string().optional(), - offset: z.number().optional(), - size: z.number().optional(), - toYear: z.number().optional(), - type: z.string().optional(), +const albumListEntry = album.omit({ + song: true, }); -const albumList = z.array(album.omit({ song: true })); +const artist = z + .object({ + album: z.array(album), + albumCount: z.string(), + coverArt: z.string().optional(), + id: z.string(), + name: z.string(), + starred: z.string().optional(), + }) + // Navidrome lastfm extension + .extend({ + artistImageUrl: z.string().optional(), + }); + +const artistListEntry = artist.pick({ + albumCount: true, + coverArt: true, + id: true, + name: true, + starred: true, +}); -const albumArtist = z.object({ - albumCount: z.string(), - artistImageUrl: z.string().optional(), +const playlist = z.object({ + changed: z.string().optional(), + comment: z.string().optional(), coverArt: z.string().optional(), + created: z.string(), + duration: z.number(), + entry: z.array(song).optional(), id: z.string(), name: z.string(), + owner: z.string(), + public: z.boolean(), + songCount: z.number(), }); -const albumArtistList = z.object({ - artist: z.array(albumArtist), +const share = z.object({ + created: z.string(), + description: z.string().optional(), + entry: z.array(song), + expires: z.string().optional(), + id: z.string(), + lastVisited: z.string().optional(), name: z.string(), + public: z.boolean(), + url: z.string(), + username: z.string(), + visitCount: z.number(), }); -const artistInfoParameters = z.object({ - count: z.number().optional(), - id: z.string(), - includeNotPresent: z.boolean().optional(), +const user = z.object({ + adminRole: z.boolean(), + commentRole: z.boolean(), + downloadRole: z.boolean(), + email: z.string(), + folder: z.array(z.number()), + jukeboxRole: z.boolean(), + playlistRole: z.boolean(), + podcastRole: z.boolean(), + scrobblingEnabled: z.boolean(), + settingsRole: z.boolean(), + shareRole: z.boolean(), + uploadRole: z.boolean(), + username: z.string(), +}); + +const shareListEntry = share.omit({ + entry: true, +}); + +const playlistListEntry = playlist.omit({ + entry: true, }); const artistInfo = z.object({ - artistInfo: z.object({ - biography: z.string().optional(), - largeImageUrl: z.string().optional(), - lastFmUrl: z.string().optional(), - mediumImageUrl: z.string().optional(), - musicBrainzId: z.string().optional(), - similarArtist: z.array( - z.object({ - albumCount: z.string(), - artistImageUrl: z.string().optional(), + biography: z.string().optional(), + largeImageUrl: z.string().optional(), + lastFmUrl: z.string().optional(), + mediumImageUrl: z.string().optional(), + musicBrainzId: z.string().optional(), + similarArtist: z.array( + z.object({ + albumCount: z.string(), + artistImageUrl: z.string().optional(), + coverArt: z.string().optional(), + id: z.string(), + name: z.string(), + }), + ), + smallImageUrl: z.string().optional(), +}); + +const albumInfo = z.object({ + largeImageUrl: z.string().optional(), + lastFmUrl: z.string().optional(), + mediumImageUrl: z.string().optional(), + musicBrainzId: z.string().optional(), + notes: z.string().optional(), + smallImageUrl: z.string().optional(), +}); + +const ping = { + parameters: z.object({}), + response: baseResponse, +}; + +const getLicense = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + license: z.object({ + email: z.string(), + licenseExpires: z.string(), + trialExpires: z.string(), + valid: z.boolean(), + }), + }), + }), +}; + +const getOpenSubsonicExtensions = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + openSubsonicExtensions: z.object({ + name: z.string(), + version: z.array(z.number()), + }), + }), + }), +}; + +const getMusicFolders = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + musicFolders: z.object({ + musicFolder: z.array(musicFolder), + }), + }), + }), +}; + +const getIndexes = { + parameters: z.object({ + ifModifiedSince: z.number().optional(), + musicFolderId: z.string().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + indexes: z.object({ + ignoredArticles: z.string(), + index: z.array( + z.object({ + artist: z.array( + z.object({ + albumCount: z.number(), + coverArt: z.string().optional(), + id: z.string(), + name: z.string(), + }), + ), + name: z.string(), + }), + ), + lastModified: z.number(), + }), + }), + }), +}; + +const getMusicDirectory = { + parameters: z.object({ + id: z.string(), + musicFolderId: z.string().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + directory: z.object({ + child: z.array( + z.object({ + artist: z.string().optional(), + coverArt: z.string().optional(), + id: z.string(), + isDir: z.boolean(), + parent: z.string(), + title: z.string(), + }), + ), + childCount: z.number(), coverArt: z.string().optional(), id: z.string(), name: z.string(), + parent: z.string(), }), - ), - smallImageUrl: z.string().optional(), + }), }), -}); +}; -const topSongsListParameters = z.object({ - artist: z.string(), // The name of the artist, not the artist ID - count: z.number().optional(), -}); +const getGenres = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + genres: z.object({ + genre: z.array(genre), + }), + }), + }), +}; -const topSongsList = z.object({ - topSongs: z.object({ - song: z.array(song), +const getArtists = { + parameters: z.object({ + musicFolderId: z.string().optional(), }), -}); + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + artists: z.object({ + ignoredArticles: z.string(), + index: z.array( + z.object({ + artist: z.array(artistListEntry), + name: z.string(), + }), + ), + }), + }), + }), +}; -const scrobbleParameters = z.object({ - id: z.string(), - submission: z.boolean().optional(), - time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to. -}); +const getArtist = { + parameters: z.object({ + id: z.string(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + artist, + }), + }), +}; -const scrobble = z.null(); +const getAlbum = { + parameters: z.object({ + id: z.string(), + musicFolderId: z.string().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + album, + }), + }), +}; -const search3 = z.object({ - searchResult3: z.object({ - album: z.array(album), - artist: z.array(albumArtist), - song: z.array(song), +const getSong = { + parameters: z.object({ + id: z.string(), }), -}); + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + song, + }), + }), +}; -const search3Parameters = z.object({ - albumCount: z.number().optional(), - albumOffset: z.number().optional(), - artistCount: z.number().optional(), - artistOffset: z.number().optional(), - musicFolderId: z.string().optional(), - query: z.string().optional(), - songCount: z.number().optional(), - songOffset: z.number().optional(), -}); +const getArtistInfo = { + parameters: z.object({ + count: z.number().optional(), + id: z.string(), -const randomSongListParameters = z.object({ - fromYear: z.number().optional(), - genre: z.string().optional(), - musicFolderId: z.string().optional(), - size: z.number().optional(), - toYear: z.number().optional(), -}); + // Whether to return artists that are not present in the media library. + includeNotPresent: z.boolean().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + artistInfo, + }), + }), +}; -const randomSongList = z.object({ - randomSongs: z.object({ - song: z.array(song), +const getArtistInfo2 = { + parameters: getArtistInfo.parameters, + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + artistInfo2: artistInfo, + }), }), -}); +}; + +const getAlbumInfo = { + parameters: z.object({ + id: z.string(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + albumInfo, + }), + }), +}; -export const ssType = { - _parameters: { - albumList: albumListParameters, - artistInfo: artistInfoParameters, - authenticate: authenticateParameters, - createFavorite: createFavoriteParameters, - randomSongList: randomSongListParameters, - removeFavorite: removeFavoriteParameters, - scrobble: scrobbleParameters, - search3: search3Parameters, - setRating: setRatingParameters, - topSongsList: topSongsListParameters, +const getAlbumInfo2 = { + parameters: getAlbumInfo.parameters, + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + albumInfo2: albumInfo, + }), + }), +}; + +const getSimilarSongs = { + parameters: z.object({ + count: z.number().optional(), + id: z.string(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + similarSongs: z.object({ + song: z.array(song), + }), + }), + }), +}; + +const getSimilarSongs2 = { + parameters: getSimilarSongs.parameters, + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + similarSongs2: z.object({ + song: z.array(song), + }), + }), + }), +}; + +const getTopSongs = { + parameters: z.object({ + artist: z.string(), + count: z.number().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + topSongs: z.object({ + song: z.array(song), + }), + }), + }), +}; + +export enum AlbumListSortType { + ALPHABETICAL_BY_ARTIST = 'alphabeticalByArtist', + ALPHABETICAL_BY_NAME = 'alphabeticalByName', + BY_GENRE = 'byGenre', + BY_YEAR = 'byYear', + FREQUENT = 'frequent', + NEWEST = 'newest', + RANDOM = 'random', + RECENT = 'recent', + STARRED = 'starred', +} + +const getAlbumList = { + enum: { + AlbumListSortType, }, - _response: { + parameters: z + .object({ + fromYear: z.number().optional(), + genre: z.string().optional(), + musicFolderId: z.string().optional(), + offset: z.number().optional(), + size: z.number().optional(), + toYear: z.number().optional(), + type: z.nativeEnum(AlbumListSortType), + }) + .refine( + (val) => { + if (val.type === AlbumListSortType.BY_YEAR) { + return val.fromYear !== undefined && val.toYear !== undefined; + } + + return true; + }, + { + message: 'Parameters "fromYear" and "toYear" are required when using sort "byYear"', + }, + ) + .refine( + (val) => { + if (val.type === AlbumListSortType.BY_GENRE) { + return val.genre !== undefined; + } + + return true; + }, + { message: 'Parameter "genre" is required when using sort "byGenre"' }, + ), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + albumList: z.object({ + album: z.array(albumListEntry), + }), + }), + }), +}; + +const getAlbumList2 = { + enum: getAlbumList.enum, + parameters: getAlbumList.parameters, + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + albumList2: z.object({ + album: z.array(albumListEntry), + }), + }), + }), +}; + +const getRandomSongs = { + parameters: z.object({ + fromYear: z.number().optional(), + genre: z.string().optional(), + musicFolderId: z.string().optional(), + size: z.number().optional(), + toYear: z.number().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + randomSongs: z.object({ + song: z.array(song), + }), + }), + }), +}; + +const getSongsByGenre = { + parameters: z.object({ + count: z.number().optional(), + genre: z.string(), + musicFolderId: z.string().optional(), + offset: z.number().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + songsByGenre: z.object({ + song: z.array(song), + }), + }), + }), +}; + +const getNowPlaying = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + nowPlaying: z.object({ + entry: z.array( + z.object({ + minutesAgo: z.number(), + playerId: z.number(), + song, + username: z.string(), + }), + ), + }), + }), + }), +}; + +const getStarred = { + parameters: z.object({ + musicFolderId: z.string().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + starred: z.object({ + album: z.array(albumListEntry), + artist: z.array(artistListEntry), + song: z.array(song), + }), + }), + }), +}; + +const getStarred2 = { + parameters: getStarred.parameters, + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + starred2: z.object({ + album: z.array(albumListEntry), + artist: z.array(artistListEntry), + song: z.array(song), + }), + }), + }), +}; + +const search3 = { + parameters: z.object({ + albumCount: z.number().optional(), + albumOffset: z.number().optional(), + artistCount: z.number().optional(), + artistOffset: z.number().optional(), + musicFolderId: z.string().optional(), + query: z.string().or(z.literal('""')), + songCount: z.number().optional(), + songOffset: z.number().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + searchResult3: z.object({ + album: z.array(albumListEntry), + artist: z.array(artistListEntry), + song: z.array(song), + }), + }), + }), +}; + +const getPlaylists = { + parameters: z.object({ + username: z.string().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + playlists: z.object({ + playlist: z.array(playlistListEntry), + }), + }), + }), +}; + +const getPlaylist = { + parameters: z.object({ + id: z.string(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + playlist, + }), + }), +}; + +const createPlaylist = { + parameters: z.object({ + name: z.string(), + playlistId: z.string().optional(), + songId: z.array(z.string()).optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + playlist, + }), + }), +}; + +const updatePlaylist = { + parameters: z.object({ + comment: z.string().optional(), + name: z.string().optional(), + playlistId: z.string(), + public: z.boolean().optional(), + songIdToAdd: z.array(z.string()).optional(), + songIdToRemove: z.array(z.string()).optional(), + }), + response: baseResponse, +}; + +const deletePlaylist = { + parameters: z.object({ + id: z.string(), + }), + response: baseResponse, +}; + +const getLyrics = { + parameters: z.object({ + artist: z.string().optional(), + title: z.string().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + lyrics: z.object({ + artist: z.string(), + title: z.string(), + value: z.string(), + }), + }), + }), +}; + +const star = { + parameters: z.object({ + albumId: z.array(z.string()).optional(), + artistId: z.array(z.string()).optional(), + id: z.array(z.string()).optional(), + }), + response: baseResponse, +}; + +const unstar = { + parameters: z.object({ + albumId: z.array(z.string()).optional(), + artistId: z.array(z.string()).optional(), + id: z.array(z.string()).optional(), + }), + response: baseResponse, +}; + +const setRating = { + parameters: z.object({ + id: z.string(), + rating: z.number(), + }), + response: baseResponse, +}; + +const scrobble = { + parameters: z.object({ + id: z.string(), + submission: z.boolean().optional(), // Whether this is a “submission” or a “now playing” notification. + time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to. + }), + response: baseResponse, +}; + +const getShares = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + shares: z.object({ + share: z.array(shareListEntry), + }), + }), + }), +}; + +const createShare = { + parameters: z.object({ + description: z.string().optional(), + expires: z.number().optional(), + id: z.array(z.string()), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + shares: z.object({ + share: z.array(shareListEntry), + }), + }), + }), +}; + +const updateShare = { + parameters: z.object({ + description: z.string().optional(), + expires: z.number().optional(), + id: z.string(), + }), + response: baseResponse, +}; + +const deleteShare = { + parameters: z.object({ + id: z.string(), + }), + response: baseResponse, +}; + +const getInternetRadioStations = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + internetRadioStations: z.object({ + entry: z.array( + z.object({ + id: z.string(), + name: z.string(), + streamUrl: z.string(), + }), + ), + }), + }), + }), +}; + +const createInternetRadioStation = { + parameters: z.object({ + homePageUrl: z.string().optional(), + name: z.string(), + streamUrl: z.string(), + }), + response: baseResponse, +}; + +const updateInternetRadioStation = { + parameters: z.object({ + homePageUrl: z.string().optional(), + id: z.string(), + name: z.string(), + streamUrl: z.string(), + }), + response: baseResponse, +}; + +const deleteInternetRadioStation = { + parameters: z.object({ + id: z.string(), + }), + response: baseResponse, +}; + +const getUser = { + parameters: z.object({ + username: z.string(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + user, + }), + }), +}; + +const getUsers = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + users: z.object({ + user: z.array(user), + }), + }), + }), +}; + +const createUser = { + parameters: z.object({ + adminRole: z.boolean().optional(), + commentRole: z.boolean().optional(), + coverArtRole: z.boolean().optional(), + downloadRole: z.boolean().optional(), + email: z.string(), + folder: z.array(z.number()).optional(), + jukeboxRole: z.boolean().optional(), + ldapAuthenticated: z.boolean().optional(), + musicFolderId: z.array(z.string()).optional(), + password: z.string(), + playlistRole: z.boolean().optional(), + podcastRole: z.boolean().optional(), + scrobblingEnabled: z.boolean().optional(), + settingsRole: z.boolean().optional(), + shareRole: z.boolean().optional(), + streamRole: z.boolean().optional(), + uploadRole: z.boolean().optional(), + username: z.string(), + }), + response: baseResponse, +}; + +const updateUser = { + parameters: z.object({ + adminRole: z.boolean().optional(), + commentRole: z.boolean().optional(), + coverArtRole: z.boolean().optional(), + downloadRole: z.boolean().optional(), + email: z.string().optional(), + folder: z.array(z.number()).optional(), + jukeboxRole: z.boolean().optional(), + ldapAuthenticated: z.boolean().optional(), + musicFolderId: z.array(z.string()).optional(), + password: z.string().optional(), + playlistRole: z.boolean().optional(), + podcastRole: z.boolean().optional(), + scrobblingEnabled: z.boolean().optional(), + settingsRole: z.boolean().optional(), + shareRole: z.boolean().optional(), + streamRole: z.boolean().optional(), + uploadRole: z.boolean().optional(), + username: z.string(), + }), + response: baseResponse, +}; + +const deleteUser = { + parameters: z.object({ + username: z.string(), + }), + response: baseResponse, +}; + +const changePassword = { + parameters: z.object({ + password: z.string(), + }), + response: baseResponse, +}; + +const getScanStatus = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + scanStatus: z.object({ + count: z.number(), + scanning: z.boolean(), + }), + }), + }), +}; + +const startScan = { + parameters: z.object({}), + response: baseResponse, +}; + +export const SubsonicApi = { + _baseTypes: { album, - albumArtist, - albumArtistList, - albumList, + albumInfo, + albumListEntry, + artist, artistInfo, - authenticate, + artistListEntry, baseResponse, - createFavorite, - musicFolderList, - randomSongList, - removeFavorite, - scrobble, - search3, - setRating, + genre, + musicFolder, + playlist, + playlistListEntry, + share, + shareListEntry, song, - topSongsList, + user, }, + changePassword, + createInternetRadioStation, + createPlaylist, + createShare, + createUser, + deleteInternetRadioStation, + deletePlaylist, + deleteShare, + deleteUser, + getAlbum, + getAlbumInfo, + getAlbumInfo2, + getAlbumList, + getAlbumList2, + getArtist, + getArtistInfo, + getArtistInfo2, + getArtists, + getGenres, + getIndexes, + getInternetRadioStations, + getLicense, + getLyrics, + getMusicDirectory, + getMusicFolders, + getNowPlaying, + getOpenSubsonicExtensions, + getPlaylist, + getPlaylists, + getRandomSongs, + getScanStatus, + getShares, + getSimilarSongs, + getSimilarSongs2, + getSong, + getSongsByGenre, + getStarred, + getStarred2, + getTopSongs, + getUser, + getUsers, + ping, + scrobble, + search3, + setRating, + star, + startScan, + unstar, + updateInternetRadioStation, + updatePlaylist, + updateShare, + updateUser, }; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 5165c7fb1..75ce92769 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -124,7 +124,7 @@ export interface BasePaginatedResponse { error?: string | any; items: T; startIndex: number; - totalRecordCount: number; + totalRecordCount: number | null; } export type AuthenticationResponse = { @@ -306,7 +306,9 @@ export type GenreListResponse = BasePaginatedResponse | null | undefine export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs; export enum GenreListSort { + ALBUM_COUNT = 'albumCount', NAME = 'name', + SONG_COUNT = 'songCount', } export type GenreListQuery = { @@ -330,10 +332,14 @@ type GenreListSortMap = { export const genreListSortMap: GenreListSortMap = { jellyfin: { + albumCount: undefined, name: JFGenreListSort.NAME, + songCount: undefined, }, navidrome: { + albumCount: undefined, name: NDGenreListSort.NAME, + songCount: undefined, }, subsonic: { name: undefined, @@ -370,7 +376,12 @@ export type AlbumListQuery = { navidrome?: Partial>; }; artistIds?: string[]; + genre?: string; + isCompilation?: boolean; + isFavorite?: boolean; limit?: number; + maxYear?: number; + minYear?: number; musicFolderId?: string; searchTerm?: string; sortBy: AlbumListSort; @@ -481,8 +492,13 @@ export type SongListQuery = { }; albumIds?: string[]; artistIds?: string[]; + genre?: string; + genreId?: string; imageSize?: number; + isFavorite?: boolean; limit?: number; + maxYear?: number; + minYear?: number; musicFolderId?: string; searchTerm?: string; sortBy: SongListSort; @@ -802,6 +818,7 @@ export type CreatePlaylistBody = { }; comment?: string; name: string; + public?: boolean; }; export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs; @@ -826,6 +843,11 @@ export type UpdatePlaylistBody = { comment?: string; genres?: Genre[]; name: string; + owner?: string; + ownerId?: string; + public?: boolean; + rules?: Record; + sync?: boolean; }; export type UpdatePlaylistArgs = { @@ -917,10 +939,9 @@ export type PlaylistSongListResponse = BasePaginatedResponse | null | un export type PlaylistSongListQuery = { id: string; - limit?: number; - sortBy?: SongListSort; - sortOrder?: SortOrder; - startIndex: number; + searchTerm?: string; + sortBy: SongListSort; + sortOrder: SortOrder; }; export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs; @@ -1014,7 +1035,7 @@ export type SearchQuery = { albumLimit?: number; albumStartIndex?: number; musicFolderId?: string; - query?: string; + query: string; songLimit?: number; songStartIndex?: number; }; @@ -1139,3 +1160,48 @@ export type FontData = { postscriptName: string; style: string; }; + +export type ControllerEndpoint = Partial<{ + addToPlaylist: (args: AddToPlaylistArgs) => Promise; + authenticate: ( + url: string, + body: { password: string; username: string }, + ) => Promise; + clearPlaylist: () => void; + createFavorite: (args: FavoriteArgs) => Promise; + createPlaylist: (args: CreatePlaylistArgs) => Promise; + deleteFavorite: (args: FavoriteArgs) => Promise; + deletePlaylist: (args: DeletePlaylistArgs) => Promise; + getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise; + getAlbumArtistList: (args: AlbumArtistListArgs) => Promise; + getAlbumArtistListCount: (args: AlbumArtistListArgs) => Promise; + getAlbumDetail: (args: AlbumDetailArgs) => Promise; + getAlbumList: (args: AlbumListArgs) => Promise; + getAlbumListCount: (args: AlbumListArgs) => Promise; + getAlbumSongList: (args: AlbumDetailArgs) => Promise; // TODO + getArtistDetail: () => void; + getArtistInfo: (args: any) => void; + getArtistList: (args: ArtistListArgs) => Promise; + getFavoritesList: () => void; + getFolderItemList: () => void; + getFolderList: () => void; + getFolderSongs: () => void; + getGenreList: (args: GenreListArgs) => Promise; + getLyrics: (args: LyricsArgs) => Promise; + getMusicFolderList: (args: MusicFolderListArgs) => Promise; + getPlaylistDetail: (args: PlaylistDetailArgs) => Promise; + getPlaylistList: (args: PlaylistListArgs) => Promise; + getPlaylistListCount: (args: PlaylistListArgs) => Promise; + getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; + getRandomSongList: (args: RandomSongListArgs) => Promise; + getSongDetail: (args: SongDetailArgs) => Promise; + getSongList: (args: SongListArgs) => Promise; + getSongListCount: (args: SongListArgs) => Promise; + getTopSongs: (args: TopSongListArgs) => Promise; + getUserList: (args: UserListArgs) => Promise; + removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; + scrobble: (args: ScrobbleArgs) => Promise; + search: (args: SearchArgs) => Promise; + setRating: (args: SetRatingArgs) => Promise; + updatePlaylist: (args: UpdatePlaylistArgs) => Promise; +}>; diff --git a/src/renderer/api/utils.ts b/src/renderer/api/utils.ts index 0063fae95..f3a8915c9 100644 --- a/src/renderer/api/utils.ts +++ b/src/renderer/api/utils.ts @@ -1,8 +1,20 @@ import { AxiosHeaders } from 'axios'; -import { z } from 'zod'; import { toast } from '/@/renderer/components'; import { useAuthStore } from '/@/renderer/store'; import { ServerListItem } from '/@/renderer/types'; +import { + Album, + AlbumArtist, + AlbumArtistListSort, + AlbumListSort, + QueueSong, + SongListSort, + SortOrder, +} from '/@/renderer/api/types'; +import orderBy from 'lodash/orderBy'; +import reverse from 'lodash/reverse'; +import shuffle from 'lodash/shuffle'; +import { z } from 'zod'; // Since ts-rest client returns a strict response type, we need to add the headers to the body object export const resultWithHeaders = (itemSchema: ItemType) => { @@ -38,3 +50,178 @@ export const authenticationFailure = (currentServer: ServerListItem | null) => { useAuthStore.getState().actions.setCurrentServer(null); } }; + +export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: SortOrder) => { + let results = albums; + + const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; + + switch (sortBy) { + case AlbumListSort.ALBUM_ARTIST: + results = orderBy( + results, + ['albumArtist', (v) => v.name.toLowerCase()], + [order, 'asc'], + ); + break; + case AlbumListSort.DURATION: + results = orderBy(results, ['duration'], [order]); + break; + case AlbumListSort.FAVORITED: + results = orderBy(results, ['starred'], [order]); + break; + case AlbumListSort.NAME: + results = orderBy(results, [(v) => v.name.toLowerCase()], [order]); + break; + case AlbumListSort.PLAY_COUNT: + results = orderBy(results, ['playCount'], [order]); + break; + case AlbumListSort.RANDOM: + results = shuffle(results); + break; + case AlbumListSort.RECENTLY_ADDED: + results = orderBy(results, ['createdAt'], [order]); + break; + case AlbumListSort.RECENTLY_PLAYED: + results = orderBy(results, ['lastPlayedAt'], [order]); + break; + case AlbumListSort.RATING: + results = orderBy(results, ['userRating'], [order]); + break; + case AlbumListSort.YEAR: + results = orderBy(results, ['releaseYear'], [order]); + break; + case AlbumListSort.SONG_COUNT: + results = orderBy(results, ['songCount'], [order]); + break; + default: + break; + } + + return results; +}; + +export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder: SortOrder) => { + let results = songs; + + const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; + + switch (sortBy) { + case SongListSort.ALBUM: + results = orderBy( + results, + [(v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'], + [order, 'asc', 'asc'], + ); + break; + + case SongListSort.ALBUM_ARTIST: + results = orderBy( + results, + ['albumArtist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'], + [order, order, 'asc', 'asc'], + ); + break; + + case SongListSort.ARTIST: + results = orderBy( + results, + ['artist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'], + [order, order, 'asc', 'asc'], + ); + break; + + case SongListSort.DURATION: + results = orderBy(results, ['duration'], [order]); + break; + + case SongListSort.FAVORITED: + results = orderBy(results, ['userFavorite', (v) => v.name.toLowerCase()], [order]); + break; + + case SongListSort.GENRE: + results = orderBy( + results, + [ + (v) => v.genres?.[0].name.toLowerCase(), + (v) => v.album?.toLowerCase(), + 'discNumber', + 'trackNumber', + ], + [order, order, 'asc', 'asc'], + ); + break; + + case SongListSort.ID: + if (order === 'desc') { + results = reverse(results); + } + break; + + case SongListSort.NAME: + results = orderBy(results, [(v) => v.name.toLowerCase()], [order]); + break; + + case SongListSort.PLAY_COUNT: + results = orderBy(results, ['playCount'], [order]); + break; + + case SongListSort.RANDOM: + results = shuffle(results); + break; + + case SongListSort.RATING: + results = orderBy(results, ['userRating', (v) => v.name.toLowerCase()], [order]); + break; + + case SongListSort.RECENTLY_ADDED: + results = orderBy(results, ['created'], [order]); + break; + + case SongListSort.YEAR: + results = orderBy( + results, + ['year', (v) => v.album?.toLowerCase(), 'discNumber', 'track'], + [order, 'asc', 'asc', 'asc'], + ); + break; + + default: + break; + } + + return results; +}; + +export const sortAlbumArtistList = ( + artists: AlbumArtist[], + sortBy: AlbumArtistListSort, + sortOrder: SortOrder, +) => { + const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; + + let results = artists; + + switch (sortBy) { + case AlbumArtistListSort.ALBUM_COUNT: + results = orderBy(artists, ['albumCount', (v) => v.name.toLowerCase()], [order, 'asc']); + break; + + case AlbumArtistListSort.NAME: + results = orderBy(artists, [(v) => v.name.toLowerCase()], [order]); + break; + + case AlbumArtistListSort.FAVORITED: + results = orderBy(artists, ['starred'], [order]); + break; + + case AlbumArtistListSort.RATING: + results = orderBy(artists, ['userRating'], [order]); + break; + + default: + break; + } + + return results; +}; diff --git a/src/renderer/components/card/card-rows.tsx b/src/renderer/components/card/card-rows.tsx index 40b1fa993..21dc29ffd 100644 --- a/src/renderer/components/card/card-rows.tsx +++ b/src/renderer/components/card/card-rows.tsx @@ -275,7 +275,7 @@ export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow } = { name: { property: 'name', route: { - route: AppRoute.PLAYLISTS_DETAIL, + route: AppRoute.PLAYLISTS_DETAIL_SONGS, slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }], }, }, 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 1e7221cd1..3b1604ced 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -34,6 +34,7 @@ interface UseAgGridProps { columnType?: 'albumDetail' | 'generic'; contextMenu: SetContextMenuItems; customFilters?: Partial; + isClientSide?: boolean; isClientSideSort?: boolean; isSearchParams?: boolean; itemCount?: number; @@ -43,6 +44,8 @@ interface UseAgGridProps { tableRef: MutableRefObject; } +const BLOCK_SIZE = 500; + export const useVirtualTable = ({ server, tableRef, @@ -52,6 +55,7 @@ export const useVirtualTable = ({ itemCount, customFilters, isSearchParams, + isClientSide, isClientSideSort, columnType, }: UseAgGridProps) => { @@ -182,6 +186,19 @@ export const useVirtualTable = ({ return; } + if (results.totalRecordCount === null) { + const hasMoreRows = results?.items?.length === BLOCK_SIZE; + const lastRowIndex = hasMoreRows + ? undefined + : (properties.filter.offset || 0) + results.items.length; + + params.successCallback( + results?.items || [], + hasMoreRows ? undefined : lastRowIndex, + ); + return; + } + params.successCallback(results?.items || [], results?.totalRecordCount || 0); }, rowCount: undefined, @@ -321,6 +338,7 @@ export const useVirtualTable = ({ alwaysShowHorizontalScroll: true, autoFitColumns: properties.table.autoFit, blockLoadDebounceMillis: 200, + cacheBlockSize: 500, getRowId: (data: GetRowIdParams) => data.data.id, infiniteInitialRowCount: itemCount || 100, pagination: isPaginationEnabled, @@ -335,10 +353,11 @@ export const useVirtualTable = ({ : undefined, rowBuffer: 20, rowHeight: properties.table.rowHeight || 40, - rowModelType: 'infinite' as RowModelType, + rowModelType: isClientSide ? 'clientSide' : ('infinite' as RowModelType), suppressRowDrag: true, }; }, [ + isClientSide, isPaginationEnabled, isSearchParams, itemCount, @@ -370,7 +389,9 @@ export const useVirtualTable = ({ ); break; case LibraryItem.PLAYLIST: - navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id })); + navigate( + generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }), + ); break; default: break; diff --git a/src/renderer/features/albums/components/album-list-header-filters.tsx b/src/renderer/features/albums/components/album-list-header-filters.tsx index 268458b74..9fd4eee71 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -22,6 +22,7 @@ import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { useListContext } from '/@/renderer/context/list-context'; import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters'; import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters'; +import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters'; import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared'; import { useContainerQuery } from '/@/renderer/hooks'; import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; @@ -139,14 +140,61 @@ const FILTERS = { value: AlbumListSort.YEAR, }, ], + subsonic: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), + value: AlbumListSort.ALBUM_ARTIST, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }), + value: AlbumListSort.PLAY_COUNT, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: AlbumListSort.NAME, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.random', { postProcess: 'titleCase' }), + value: AlbumListSort.RANDOM, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }), + value: AlbumListSort.RECENTLY_ADDED, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }), + value: AlbumListSort.RECENTLY_PLAYED, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.favorited', { postProcess: 'titleCase' }), + value: AlbumListSort.FAVORITED, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }), + value: AlbumListSort.YEAR, + }, + ], }; interface AlbumListHeaderFiltersProps { gridRef: MutableRefObject; + itemCount: number | undefined; tableRef: MutableRefObject; } -export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => { +export const AlbumListHeaderFilters = ({ + gridRef, + tableRef, + itemCount, +}: AlbumListHeaderFiltersProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); const { pageKey, customFilters, handlePlay } = useListContext(); @@ -159,6 +207,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil const cq = useContainerQuery(); const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.ALBUM, server, }); @@ -185,27 +234,35 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil ); const handleOpenFiltersModal = () => { + let FilterComponent; + + switch (server?.type) { + case ServerType.NAVIDROME: + FilterComponent = NavidromeAlbumFilters; + break; + case ServerType.JELLYFIN: + FilterComponent = JellyfinAlbumFilters; + break; + case ServerType.SUBSONIC: + FilterComponent = SubsonicAlbumFilters; + break; + default: + break; + } + + if (!FilterComponent) { + return; + } + openModal({ children: ( - <> - {server?.type === ServerType.NAVIDROME ? ( - - ) : ( - - )} - + ), title: 'Album Filters', }); @@ -341,8 +398,20 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil filter?._custom?.jellyfin && Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined); - return isNavidromeFilterApplied || isJellyfinFilterApplied; - }, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]); + const isSubsonicFilterApplied = + server?.type === ServerType.SUBSONIC && + (filter.maxYear || filter.minYear || filter.genre || filter.isFavorite); + + return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied; + }, [ + filter?._custom?.jellyfin, + filter?._custom?.navidrome, + filter.genre, + filter.isFavorite, + filter.maxYear, + filter.minYear, + server?.type, + ]); const isFolderFilterApplied = useMemo(() => { return filter.musicFolderId !== undefined; diff --git a/src/renderer/features/albums/components/album-list-header.tsx b/src/renderer/features/albums/components/album-list-header.tsx index 657802b0c..b301468b6 100644 --- a/src/renderer/features/albums/components/album-list-header.tsx +++ b/src/renderer/features/albums/components/album-list-header.tsx @@ -38,6 +38,7 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi const playButtonBehavior = usePlayButtonBehavior(); const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.ALBUM, server, }); @@ -94,6 +95,7 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx new file mode 100644 index 000000000..976dc42ce --- /dev/null +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -0,0 +1,143 @@ +import { Divider, Group, Stack } from '@mantine/core'; +import debounce from 'lodash/debounce'; +import { ChangeEvent, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { NumberInput, Select, Switch, Text } from '/@/renderer/components'; +import { useGenreList } from '/@/renderer/features/genres'; +import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store'; + +interface SubsonicAlbumFiltersProps { + onFilterChange: (filters: AlbumListFilter) => void; + pageKey: string; + serverId?: string; +} + +export const SubsonicAlbumFilters = ({ + onFilterChange, + pageKey, + serverId, +}: SubsonicAlbumFiltersProps) => { + const { t } = useTranslation(); + const { filter } = useListStoreByKey({ key: pageKey }); + const { setFilter } = useListStoreActions(); + + const genreListQuery = useGenreList({ + query: { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId, + }); + + const genreList = useMemo(() => { + if (!genreListQuery?.data) return []; + return genreListQuery.data.items.map((genre) => ({ + label: genre.name, + value: genre.id, + })); + }, [genreListQuery.data]); + + const handleGenresFilter = debounce((e: string | null) => { + const updatedFilters = setFilter({ + data: { + genre: e || undefined, + }, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + + onFilterChange(updatedFilters); + }, 250); + + const toggleFilters = [ + { + label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), + onChange: (e: ChangeEvent) => { + const updatedFilters = setFilter({ + data: { + isFavorite: e.target.checked ? true : undefined, + }, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + onFilterChange(updatedFilters); + }, + value: filter.isFavorite, + }, + ]; + + const handleYearFilter = debounce((e: number | string, type: 'min' | 'max') => { + let data = {}; + + if (type === 'min') { + data = { + minYear: e || undefined, + }; + } else { + data = { + maxYear: e || undefined, + }; + } + + console.log('data', data); + + const updatedFilters = setFilter({ + data, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + + onFilterChange(updatedFilters); + }, 500); + + return ( + + {toggleFilters.map((filter) => ( + + {filter.label} + + + ))} + + + handleYearFilter(e, 'min')} + /> + handleYearFilter(e, 'max')} + /> + + + + )} + + + ); +}; diff --git a/src/renderer/features/songs/queries/song-list-count-query.ts b/src/renderer/features/songs/queries/song-list-count-query.ts new file mode 100644 index 000000000..a36274c9c --- /dev/null +++ b/src/renderer/features/songs/queries/song-list-count-query.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import type { SongListQuery } from '/@/renderer/api/types'; +import type { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { getServerById } from '/@/renderer/store'; + +export const getSongListCountQuery = (query: SongListQuery) => { + const filter: Record = {}; + + if (query.searchTerm) filter.searchTerm = query.searchTerm; + if (query.genreId) filter.genreId = query.genreId; + if (query.musicFolderId) filter.musicFolderId = query.musicFolderId; + if (query.isFavorite) filter.isFavorite = query.isFavorite; + if (query.genre) filter.genre = query.genre; + + if (Object.keys(filter).length === 0) return undefined; + + return filter; +}; + +export const useSongListCount = (args: QueryHookArgs) => { + const { options, query, serverId } = args; + const server = getServerById(serverId); + + return useQuery({ + enabled: !!serverId, + queryFn: ({ signal }) => { + if (!server) throw new Error('Server not found'); + return api.controller.getSongListCount({ + apiClientProps: { + server, + signal, + }, + query, + }); + }, + queryKey: queryKeys.songs.count(serverId || '', getSongListCountQuery(query)), + ...options, + }); +}; diff --git a/src/renderer/features/songs/routes/song-list-route.tsx b/src/renderer/features/songs/routes/song-list-route.tsx index 61a8f070c..5ffb3d4ca 100644 --- a/src/renderer/features/songs/routes/song-list-route.tsx +++ b/src/renderer/features/songs/routes/song-list-route.tsx @@ -9,11 +9,11 @@ import { usePlayQueueAdd } from '/@/renderer/features/player'; import { AnimatedPage } from '/@/renderer/features/shared'; import { SongListContent } from '/@/renderer/features/songs/components/song-list-content'; import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header'; -import { useSongList } from '/@/renderer/features/songs/queries/song-list-query'; import { useCurrentServer, useListFilterByKey } from '/@/renderer/store'; import { Play } from '/@/renderer/types'; import { titleCase } from '/@/renderer/utils'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; +import { useSongListCount } from '/@/renderer/features/songs/queries/song-list-count-query'; const TrackListRoute = () => { const gridRef = useRef(null); @@ -36,6 +36,8 @@ const TrackListRoute = () => { genre_id: genreId, }, }, + genre: genreId, + genreId, }), }; @@ -74,7 +76,7 @@ const TrackListRoute = () => { return genre?.name; }, [genreId, genreList.data]); - const itemCountCheck = useSongList({ + const itemCountCheck = useSongListCount({ options: { cacheTime: 1000 * 60, staleTime: 1000 * 60, @@ -87,10 +89,7 @@ const TrackListRoute = () => { serverId: server?.id, }); - const itemCount = - itemCountCheck.data?.totalRecordCount === null - ? undefined - : itemCountCheck.data?.totalRecordCount; + const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data; const handlePlay = useCallback( async (args: { initialSongId?: string; playType: Play }) => { diff --git a/src/renderer/hooks/use-list-filter-refresh.ts b/src/renderer/hooks/use-list-filter-refresh.ts index 91ccb000b..a7a0763c4 100644 --- a/src/renderer/hooks/use-list-filter-refresh.ts +++ b/src/renderer/hooks/use-list-filter-refresh.ts @@ -10,14 +10,18 @@ import orderBy from 'lodash/orderBy'; interface UseHandleListFilterChangeProps { isClientSideSort?: boolean; + itemCount?: number; itemType: LibraryItem; server: ServerListItem | null; } +const BLOCK_SIZE = 500; + export const useListFilterRefresh = ({ server, itemType, isClientSideSort, + itemCount, }: UseHandleListFilterChangeProps) => { const queryClient = useQueryClient(); @@ -78,7 +82,7 @@ export const useListFilterRefresh = ({ const queryKey = queryKeyFn(server?.id || '', query); - const res = await queryClient.fetchQuery({ + const results = (await queryClient.fetchQuery({ queryFn: async ({ signal }) => { return queryFn({ apiClientProps: { @@ -89,20 +93,39 @@ export const useListFilterRefresh = ({ }); }, queryKey, - }); + })) as BasePaginatedResponse; - if (isClientSideSort && res?.items) { + if (isClientSideSort && results?.items) { const sortedResults = orderBy( - res.items, + results.items, [(item) => String(item[filter.sortBy]).toLowerCase()], filter.sortOrder === 'DESC' ? ['desc'] : ['asc'], ); - params.successCallback(sortedResults || [], res?.totalRecordCount || 0); + params.successCallback( + sortedResults || [], + results?.totalRecordCount || itemCount, + ); + return; + } + + if (results.totalRecordCount === null) { + const hasMoreRows = results?.items?.length === BLOCK_SIZE; + const lastRowIndex = hasMoreRows + ? undefined + : (filter.offset || 0) + results.items.length; + + params.successCallback( + results?.items || [], + hasMoreRows ? undefined : lastRowIndex, + ); return; } - params.successCallback(res?.items || [], res?.totalRecordCount || 0); + params.successCallback( + results?.items || [], + results?.totalRecordCount || itemCount, + ); }, rowCount: undefined, @@ -112,7 +135,7 @@ export const useListFilterRefresh = ({ tableRef.current?.api.purgeInfiniteCache(); tableRef.current?.api.ensureIndexVisible(0, 'top'); }, - [isClientSideSort, queryClient, queryFn, queryKeyFn, server], + [isClientSideSort, itemCount, queryClient, queryFn, queryKeyFn, server], ); const handleRefreshGrid = useCallback( diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index 41cbc6283..83470a273 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -18,10 +18,6 @@ const AlbumListRoute = lazy(() => import('/@/renderer/features/albums/routes/alb const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route')); -const PlaylistDetailRoute = lazy( - () => import('/@/renderer/features/playlists/routes/playlist-detail-route'), -); - const PlaylistDetailSongListRoute = lazy( () => import('/@/renderer/features/playlists/routes/playlist-detail-song-list-route'), ); @@ -136,11 +132,6 @@ export const AppRouter = () => { errorElement={} path={AppRoute.PLAYLISTS} /> - } - errorElement={} - path={AppRoute.PLAYLISTS_DETAIL} - /> } errorElement={}