diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index cc3e3037..1dc92383 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -1,119 +1,11 @@ import { useAuthStore } from '/@/renderer/store'; import { toast } from '/@/renderer/components/toast/index'; -import type { - AlbumDetailArgs, - AlbumListArgs, - SongListArgs, - SongDetailArgs, - AlbumArtistDetailArgs, - AlbumArtistListArgs, - SetRatingArgs, - ShareItemArgs, - GenreListArgs, - CreatePlaylistArgs, - DeletePlaylistArgs, - 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, - ServerInfo, - ServerInfoArgs, - StructuredLyricsArgs, - StructuredLyric, - SimilarSongsArgs, - Song, - ServerType, - ShareItemResponse, - MoveItemArgs, - DownloadArgs, - TranscodingArgs, -} from '/@/renderer/api/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 type { ServerType, ControllerEndpoint, AuthenticationResponse } from '/@/renderer/api/types'; +import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller'; +import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; +import { JellyfinController } 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; - getDownloadUrl: (args: DownloadArgs) => string; - 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; - getServerInfo: (args: ServerInfoArgs) => Promise; - getSimilarSongs: (args: SimilarSongsArgs) => Promise; - getSongDetail: (args: SongDetailArgs) => Promise; - getSongList: (args: SongListArgs) => Promise; - getStructuredLyrics: (args: StructuredLyricsArgs) => Promise; - getTopSongs: (args: TopSongListArgs) => Promise; - getTranscodingUrl: (args: TranscodingArgs) => string; - getUserList: (args: UserListArgs) => Promise; - movePlaylistItem: (args: MoveItemArgs) => Promise; - removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; - scrobble: (args: ScrobbleArgs) => Promise; - search: (args: SearchArgs) => Promise; - setRating: (args: SetRatingArgs) => Promise; - shareItem: (args: ShareItemArgs) => Promise; - updatePlaylist: (args: UpdatePlaylistArgs) => Promise; -}>; - type ApiController = { jellyfin: ControllerEndpoint; navidrome: ControllerEndpoint; @@ -121,133 +13,15 @@ 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, - getDownloadUrl: jfController.getDownloadUrl, - 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, - getServerInfo: jfController.getServerInfo, - getSimilarSongs: jfController.getSimilarSongs, - getSongDetail: jfController.getSongDetail, - getSongList: jfController.getSongList, - getStructuredLyrics: undefined, - getTopSongs: jfController.getTopSongList, - getTranscodingUrl: jfController.getTranscodingUrl, - getUserList: undefined, - movePlaylistItem: jfController.movePlaylistItem, - removeFromPlaylist: jfController.removeFromPlaylist, - scrobble: jfController.scrobble, - search: jfController.search, - setRating: undefined, - shareItem: 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, - getDownloadUrl: ssController.getDownloadUrl, - 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, - getServerInfo: ndController.getServerInfo, - getSimilarSongs: ndController.getSimilarSongs, - getSongDetail: ndController.getSongDetail, - getSongList: ndController.getSongList, - getStructuredLyrics: ssController.getStructuredLyrics, - getTopSongs: ssController.getTopSongList, - getTranscodingUrl: ssController.getTranscodingUrl, - getUserList: ndController.getUserList, - movePlaylistItem: ndController.movePlaylistItem, - removeFromPlaylist: ndController.removeFromPlaylist, - scrobble: ssController.scrobble, - search: ssController.search3, - setRating: ssController.setRating, - shareItem: ndController.shareItem, - 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, - getDownloadUrl: ssController.getDownloadUrl, - getFavoritesList: undefined, - getFolderItemList: undefined, - getFolderList: undefined, - getFolderSongs: undefined, - getGenreList: undefined, - getLyrics: undefined, - getMusicFolderList: ssController.getMusicFolderList, - getPlaylistDetail: undefined, - getPlaylistList: undefined, - getServerInfo: ssController.getServerInfo, - getSimilarSongs: ssController.getSimilarSongs, - getSongDetail: undefined, - getSongList: undefined, - getStructuredLyrics: ssController.getStructuredLyrics, - getTopSongs: ssController.getTopSongList, - getTranscodingUrl: ssController.getTranscodingUrl, - getUserList: undefined, - scrobble: ssController.scrobble, - search: ssController.search3, - setRating: undefined, - shareItem: undefined, - updatePlaylist: undefined, - }, + jellyfin: JellyfinController, + navidrome: NavidromeController, + subsonic: SubsonicController, }; -const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => { +const apiController = ( + endpoint: K, + type?: ServerType, +): NonNullable => { const serverType = type || useAuthStore.getState().currentServer?.type; if (!serverType) { @@ -277,344 +51,127 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => ); } - return endpoints[serverType][endpoint]; + return controllerFn; }; -const authenticate = async ( - url: string, - body: { legacy?: boolean; password: string; username: string }, - type: ServerType, -) => { - return (apiController('authenticate', type) as ControllerEndpoint['authenticate'])?.(url, body); -}; +export interface GeneralController extends Omit, 'authenticate'> { + authenticate: ( + url: string, + body: { legacy?: boolean; password: string; username: string }, + type: ServerType, + ) => Promise; +} -const getAlbumList = async (args: AlbumListArgs) => { - return ( - apiController( - 'getAlbumList', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getAlbumList'] - )?.(args); -}; - -const getAlbumDetail = async (args: AlbumDetailArgs) => { - return ( - apiController( - 'getAlbumDetail', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getAlbumDetail'] - )?.(args); -}; - -const getSongList = async (args: SongListArgs) => { - return ( - apiController( - 'getSongList', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getSongList'] - )?.(args); -}; - -const getSongDetail = async (args: SongDetailArgs) => { - return ( - apiController( - 'getSongDetail', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getSongDetail'] - )?.(args); -}; - -const getMusicFolderList = async (args: MusicFolderListArgs) => { - return ( - apiController( - 'getMusicFolderList', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getMusicFolderList'] - )?.(args); -}; - -const getGenreList = async (args: GenreListArgs) => { - return ( - apiController( - 'getGenreList', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getGenreList'] - )?.(args); -}; - -const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs) => { - return ( - apiController( - 'getAlbumArtistDetail', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getAlbumArtistDetail'] - )?.(args); -}; - -const getAlbumArtistList = async (args: AlbumArtistListArgs) => { - return ( - apiController( - 'getAlbumArtistList', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getAlbumArtistList'] - )?.(args); -}; - -const getArtistList = async (args: ArtistListArgs) => { - return ( - apiController( - 'getArtistList', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getArtistList'] - )?.(args); -}; - -const getPlaylistList = async (args: PlaylistListArgs) => { - return ( - apiController( - 'getPlaylistList', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getPlaylistList'] - )?.(args); -}; - -const createPlaylist = async (args: CreatePlaylistArgs) => { - return ( - apiController( - 'createPlaylist', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['createPlaylist'] - )?.(args); -}; - -const updatePlaylist = async (args: UpdatePlaylistArgs) => { - return ( - apiController( - 'updatePlaylist', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['updatePlaylist'] - )?.(args); -}; - -const deletePlaylist = async (args: DeletePlaylistArgs) => { - return ( - apiController( - 'deletePlaylist', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['deletePlaylist'] - )?.(args); -}; - -const addToPlaylist = async (args: AddToPlaylistArgs) => { - return ( - apiController( - 'addToPlaylist', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['addToPlaylist'] - )?.(args); -}; - -const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => { - return ( - apiController( - 'removeFromPlaylist', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['removeFromPlaylist'] - )?.(args); -}; - -const getPlaylistDetail = async (args: PlaylistDetailArgs) => { - return ( - apiController( - 'getPlaylistDetail', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getPlaylistDetail'] - )?.(args); -}; - -const getPlaylistSongList = async (args: PlaylistSongListArgs) => { - return ( - apiController( - 'getPlaylistSongList', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getPlaylistSongList'] - )?.(args); -}; - -const getUserList = async (args: UserListArgs) => { - return ( - apiController( - 'getUserList', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getUserList'] - )?.(args); -}; - -const createFavorite = async (args: FavoriteArgs) => { - return ( - apiController( - 'createFavorite', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['createFavorite'] - )?.(args); -}; - -const deleteFavorite = async (args: FavoriteArgs) => { - return ( - apiController( - 'deleteFavorite', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['deleteFavorite'] - )?.(args); -}; - -const updateRating = async (args: SetRatingArgs) => { - return ( - apiController( - 'setRating', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['setRating'] - )?.(args); -}; - -const shareItem = async (args: ShareItemArgs) => { - return ( - apiController( - 'shareItem', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['shareItem'] - )?.(args); -}; - -const getTopSongList = async (args: TopSongListArgs) => { - return ( - apiController( - 'getTopSongs', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getTopSongs'] - )?.(args); -}; - -const scrobble = async (args: ScrobbleArgs) => { - return ( - apiController( - 'scrobble', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['scrobble'] - )?.(args); -}; - -const search = async (args: SearchArgs) => { - return ( - apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search'] - )?.(args); -}; - -const getRandomSongList = async (args: RandomSongListArgs) => { - return ( - apiController( - 'getRandomSongList', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getRandomSongList'] - )?.(args); -}; - -const getLyrics = async (args: LyricsArgs) => { - return ( - apiController( - 'getLyrics', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getLyrics'] - )?.(args); -}; - -const getServerInfo = async (args: ServerInfoArgs) => { - return ( - apiController( - 'getServerInfo', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getServerInfo'] - )?.(args); -}; - -const getStructuredLyrics = async (args: StructuredLyricsArgs) => { - return ( - apiController( - 'getStructuredLyrics', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getStructuredLyrics'] - )?.(args); -}; - -const getSimilarSongs = async (args: SimilarSongsArgs) => { - return ( - apiController( - 'getSimilarSongs', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getSimilarSongs'] - )?.(args); -}; - -const movePlaylistItem = async (args: MoveItemArgs) => { - return ( - apiController( - 'movePlaylistItem', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['movePlaylistItem'] - )?.(args); -}; - -const getDownloadUrl = (args: DownloadArgs) => { - return ( - apiController( - 'getDownloadUrl', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getDownloadUrl'] - )?.(args); -}; - -const getTranscodingUrl = (args: TranscodingArgs) => { - return ( - apiController( - 'getTranscodingUrl', - args.apiClientProps.server?.type, - ) as ControllerEndpoint['getTranscodingUrl'] - )?.(args); -}; - -export const controller = { - addToPlaylist, - authenticate, - createFavorite, - createPlaylist, - deleteFavorite, - deletePlaylist, - getAlbumArtistDetail, - getAlbumArtistList, - getAlbumDetail, - getAlbumList, - getArtistList, - getDownloadUrl, - getGenreList, - getLyrics, - getMusicFolderList, - getPlaylistDetail, - getPlaylistList, - getPlaylistSongList, - getRandomSongList, - getServerInfo, - getSimilarSongs, - getSongDetail, - getSongList, - getStructuredLyrics, - getTopSongList, - getTranscodingUrl, - getUserList, - movePlaylistItem, - removeFromPlaylist, - scrobble, - search, - shareItem, - updatePlaylist, - updateRating, +export const controller: GeneralController = { + addToPlaylist(args) { + return apiController('addToPlaylist', args.apiClientProps.server?.type)?.(args); + }, + authenticate(url, body, type) { + return apiController('authenticate', type)(url, body); + }, + createFavorite(args) { + return apiController('createFavorite', args.apiClientProps.server?.type)?.(args); + }, + createPlaylist(args) { + return apiController('createPlaylist', args.apiClientProps.server?.type)?.(args); + }, + deleteFavorite(args) { + return apiController('deleteFavorite', args.apiClientProps.server?.type)?.(args); + }, + deletePlaylist(args) { + return apiController('deletePlaylist', args.apiClientProps.server?.type)?.(args); + }, + getAlbumArtistDetail(args) { + return apiController('getAlbumArtistDetail', args.apiClientProps.server?.type)?.(args); + }, + getAlbumArtistList(args) { + return apiController('getAlbumArtistList', args.apiClientProps.server?.type)?.(args); + }, + getAlbumArtistListCount(args) { + return apiController('getAlbumArtistListCount', args.apiClientProps.server?.type)?.(args); + }, + getAlbumDetail(args) { + return apiController('getAlbumDetail', args.apiClientProps.server?.type)?.(args); + }, + getAlbumList(args) { + return apiController('getAlbumList', args.apiClientProps.server?.type)?.(args); + }, + getAlbumListCount(args) { + return apiController('getAlbumListCount', args.apiClientProps.server?.type)?.(args); + }, + getDownloadUrl(args) { + return apiController('getDownloadUrl', args.apiClientProps.server?.type)?.(args); + }, + getGenreList(args) { + return apiController('getGenreList', args.apiClientProps.server?.type)?.(args); + }, + getLyrics(args) { + return apiController('getLyrics', args.apiClientProps.server?.type)?.(args); + }, + getMusicFolderList(args) { + return apiController('getMusicFolderList', args.apiClientProps.server?.type)?.(args); + }, + getPlaylistDetail(args) { + return apiController('getPlaylistDetail', args.apiClientProps.server?.type)?.(args); + }, + getPlaylistList(args) { + return apiController('getPlaylistList', args.apiClientProps.server?.type)?.(args); + }, + getPlaylistListCount(args) { + return apiController('getPlaylistListCount', args.apiClientProps.server?.type)?.(args); + }, + getPlaylistSongList(args) { + return apiController('getPlaylistSongList', args.apiClientProps.server?.type)?.(args); + }, + getRandomSongList(args) { + return apiController('getRandomSongList', args.apiClientProps.server?.type)?.(args); + }, + getServerInfo(args) { + return apiController('getServerInfo', args.apiClientProps.server?.type)?.(args); + }, + getSimilarSongs(args) { + return apiController('getSimilarSongs', args.apiClientProps.server?.type)?.(args); + }, + getSongDetail(args) { + return apiController('getSongDetail', args.apiClientProps.server?.type)?.(args); + }, + getSongList(args) { + return apiController('getSongList', args.apiClientProps.server?.type)?.(args); + }, + getSongListCount(args) { + return apiController('getSongListCount', args.apiClientProps.server?.type)?.(args); + }, + getStructuredLyrics(args) { + return apiController('getStructuredLyrics', args.apiClientProps.server?.type)?.(args); + }, + getTopSongs(args) { + return apiController('getTopSongs', args.apiClientProps.server?.type)?.(args); + }, + getTranscodingUrl(args) { + return apiController('getTranscodingUrl', args.apiClientProps.server?.type)?.(args); + }, + getUserList(args) { + return apiController('getUserList', args.apiClientProps.server?.type)?.(args); + }, + movePlaylistItem(args) { + return apiController('movePlaylistItem', args.apiClientProps.server?.type)?.(args); + }, + removeFromPlaylist(args) { + return apiController('removeFromPlaylist', args.apiClientProps.server?.type)?.(args); + }, + scrobble(args) { + return apiController('scrobble', args.apiClientProps.server?.type)?.(args); + }, + search(args) { + return apiController('search', args.apiClientProps.server?.type)?.(args); + }, + setRating(args) { + return apiController('setRating', args.apiClientProps.server?.type)?.(args); + }, + shareItem(args) { + return apiController('shareItem', args.apiClientProps.server?.type)?.(args); + }, + updatePlaylist(args) { + return apiController('updatePlaylist', args.apiClientProps.server?.type)?.(args); + }, }; diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 96a21cb8..cd17abee 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -1,62 +1,13 @@ import { - AuthenticationResponse, - MusicFolderListArgs, - MusicFolderListResponse, - GenreListArgs, - AlbumArtistDetailArgs, - AlbumArtistListArgs, albumArtistListSortMap, sortOrderMap, - ArtistListArgs, - artistListSortMap, - AlbumDetailArgs, - AlbumListArgs, albumListSortMap, - TopSongListArgs, - SongListArgs, songListSortMap, - AddToPlaylistArgs, - RemoveFromPlaylistArgs, - PlaylistDetailArgs, - PlaylistSongListArgs, - PlaylistListArgs, playlistListSortMap, - CreatePlaylistArgs, - CreatePlaylistResponse, - UpdatePlaylistArgs, - UpdatePlaylistResponse, - DeletePlaylistArgs, - FavoriteArgs, - FavoriteResponse, - ScrobbleArgs, - ScrobbleResponse, - GenreListResponse, - AlbumArtistDetailResponse, - AlbumArtistListResponse, - AlbumDetailResponse, - AlbumListResponse, - SongListResponse, - AddToPlaylistResponse, - RemoveFromPlaylistResponse, - PlaylistDetailResponse, - PlaylistListResponse, - SearchArgs, - SearchResponse, - RandomSongListResponse, - RandomSongListArgs, - LyricsArgs, - LyricsResponse, genreListSortMap, - SongDetailArgs, - SongDetailResponse, - ServerInfo, - ServerInfoArgs, - SimilarSongsArgs, Song, - MoveItemArgs, - DownloadArgs, - TranscodingArgs, Played, + ControllerEndpoint, } from '/@/renderer/api/types'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfNormalize } from './jellyfin-normalize'; @@ -71,759 +22,198 @@ const formatCommaDelimitedString = (value: string[]) => { return value.join(','); }; -const authenticate = async ( - url: string, - body: { - password: string; - username: string; - }, -): Promise => { - const cleanServerUrl = url.replace(/\/$/, ''); - - const res = await jfApiClient({ server: null, url: cleanServerUrl }).authenticate({ - body: { - Pw: body.password, - Username: body.username, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to authenticate'); - } - - return { - credential: res.body.AccessToken, - userId: res.body.User.Id, - username: res.body.User.Name, - }; -}; - -const getMusicFolderList = async (args: MusicFolderListArgs): Promise => { - const { apiClientProps } = args; - const userId = apiClientProps.server?.userId; - - if (!userId) throw new Error('No userId found'); - - const res = await jfApiClient(apiClientProps).getMusicFolderList({ - params: { - userId, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get genre list'); - } - - const musicFolders = res.body.Items.filter( - (folder) => folder.CollectionType === jfType._enum.collection.MUSIC, - ); - - return { - items: musicFolders.map(jfNormalize.musicFolder), - startIndex: 0, - totalRecordCount: musicFolders?.length || 0, - }; -}; - -const getGenreList = async (args: GenreListArgs): Promise => { - const { apiClientProps, query } = args; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - const res = await jfApiClient(apiClientProps).getGenreList({ - query: { - Fields: 'ItemCounts', - ParentId: query?.musicFolderId, - Recursive: true, - SearchTerm: query?.searchTerm, - SortBy: genreListSortMap.jellyfin[query.sortBy] || 'SortName', - SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, - UserId: apiClientProps.server?.userId, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get genre list'); - } - - return { - items: res.body.Items.map((item) => jfNormalize.genre(item, apiClientProps.server)), - startIndex: query.startIndex || 0, - totalRecordCount: res.body?.TotalRecordCount || 0, - }; -}; - -const getAlbumArtistDetail = async ( - args: AlbumArtistDetailArgs, -): Promise => { - const { query, apiClientProps } = args; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - const res = await jfApiClient(apiClientProps).getAlbumArtistDetail({ - params: { - id: query.id, - userId: apiClientProps.server?.userId, - }, - query: { - Fields: 'Genres, Overview', - }, - }); - - const similarArtistsRes = await jfApiClient(apiClientProps).getSimilarArtistList({ - params: { - id: query.id, - }, - query: { - Limit: 10, - }, - }); - - if (res.status !== 200 || similarArtistsRes.status !== 200) { - throw new Error('Failed to get album artist detail'); - } - - return jfNormalize.albumArtist( - { ...res.body, similarArtists: similarArtistsRes.body }, - apiClientProps.server, - ); -}; - -const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await jfApiClient(apiClientProps).getAlbumArtistList({ - query: { - Fields: 'Genres, DateCreated, ExternalUrls, Overview', - ImageTypeLimit: 1, - Limit: query.limit, - ParentId: query.musicFolderId, - Recursive: true, - SearchTerm: query.searchTerm, - SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name', - SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, - UserId: apiClientProps.server?.userId || undefined, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get album artist list'); - } - - return { - items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)), - startIndex: query.startIndex, - totalRecordCount: 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] || 'SortName,Name', - 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; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - const res = await jfApiClient(apiClientProps).getAlbumDetail({ - params: { - id: query.id, - userId: apiClientProps.server.userId, - }, - query: { - Fields: 'Genres, DateCreated, ChildCount', - }, - }); - - const songsRes = await jfApiClient(apiClientProps).getSongList({ - params: { - userId: apiClientProps.server.userId, - }, - query: { - Fields: 'Genres, DateCreated, MediaSources, ParentId', - IncludeItemTypes: 'Audio', - ParentId: query.id, - SortBy: 'ParentIndexNumber,IndexNumber,SortName', - }, - }); - - if (res.status !== 200 || songsRes.status !== 200) { - throw new Error('Failed to get album detail'); - } - - return jfNormalize.album({ ...res.body, Songs: songsRes.body.Items }, apiClientProps.server); -}; - -const getAlbumList = 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, - IncludeItemTypes: 'MusicAlbum', - Limit: query.limit, - ParentId: query.musicFolderId, - Recursive: true, - SearchTerm: query.searchTerm, - SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName', - SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, - ...query._custom?.jellyfin, - Years: yearsFilter, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get album list'); - } - - return { - items: res.body.Items.map((item) => jfNormalize.album(item, apiClientProps.server)), - startIndex: query.startIndex, - totalRecordCount: res.body.TotalRecordCount, - }; -}; - -const getTopSongList = async (args: TopSongListArgs): Promise => { - const { apiClientProps, query } = args; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - const res = await jfApiClient(apiClientProps).getTopSongsList({ - params: { - userId: apiClientProps.server?.userId, - }, - query: { - ArtistIds: query.artistId, - Fields: 'Genres, DateCreated, MediaSources, ParentId', - IncludeItemTypes: 'Audio', - Limit: query.limit, - Recursive: true, - SortBy: 'PlayCount,SortName', - SortOrder: 'Descending', - UserId: apiClientProps.server?.userId, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get top song list'); - } - - return { - items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), - startIndex: 0, - totalRecordCount: res.body.TotalRecordCount, - }; -}; - -const getSongList = 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: query.limit, - ParentId: query.musicFolderId, - Recursive: true, - SearchTerm: query.searchTerm, - SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName', - SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, - ...query._custom?.jellyfin, - Years: yearsFilter, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get song list'); - } - - let items: z.infer[]; - - // Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622 - // If the Album ID filter is passed, Jellyfin will search for - // 1. the matching album id - // 2. An album with the name of the album. - // It is this second condition causing issues, - if (query.albumIds) { - const albumIdSet = new Set(query.albumIds); - items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId)); - - if (items.length < res.body.Items.length) { - res.body.TotalRecordCount -= res.body.Items.length - items.length; - } - } else { - items = res.body.Items; - } - - return { - items: items.map((item) => - jfNormalize.song(item, apiClientProps.server, '', query.imageSize), - ), - startIndex: query.startIndex, - totalRecordCount: res.body.TotalRecordCount, - }; -}; - // Limit the query to 50 at a time to be *extremely* conservative on the // length of the full URL, since the ids are part of the query string and // not the POST body const MAX_ITEMS_PER_PLAYLIST_ADD = 50; -const addToPlaylist = async (args: AddToPlaylistArgs): Promise => { - const { query, body, apiClientProps } = args; +const VERSION_INFO: VersionInfo = [ + [ + '10.9.0', + { + [ServerFeature.LYRICS_SINGLE_STRUCTURED]: [1], + [ServerFeature.PUBLIC_PLAYLIST]: [1], + }, + ], +]; - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } +export const JellyfinController: ControllerEndpoint = { + addToPlaylist: async (args) => { + const { query, body, apiClientProps } = args; - const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD); - - for (const chunk of chunks) { - const res = await jfApiClient(apiClientProps).addToPlaylist({ - body: null, - params: { - id: query.id, - }, - query: { - Ids: chunk.join(','), - UserId: apiClientProps.server?.userId, - }, - }); - - if (res.status !== 204) { - throw new Error('Failed to add to playlist'); + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); } - } - return null; -}; + const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD); -const removeFromPlaylist = async ( - args: RemoveFromPlaylistArgs, -): Promise => { - const { query, apiClientProps } = args; + for (const chunk of chunks) { + const res = await jfApiClient(apiClientProps).addToPlaylist({ + body: null, + params: { + id: query.id, + }, + query: { + Ids: chunk.join(','), + UserId: apiClientProps.server?.userId, + }, + }); - const chunks = chunk(query.songId, MAX_ITEMS_PER_PLAYLIST_ADD); - - for (const chunk of chunks) { - const res = await jfApiClient(apiClientProps).removeFromPlaylist({ - body: null, - params: { - id: query.id, - }, - query: { - EntryIds: chunk.join(','), - }, - }); - - if (res.status !== 204) { - throw new Error('Failed to remove from playlist'); + if (res.status !== 204) { + throw new Error('Failed to add to playlist'); + } } - } - - return null; -}; - -const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise => { - const { query, apiClientProps } = args; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - const res = await jfApiClient(apiClientProps).getPlaylistDetail({ - params: { - id: query.id, - userId: apiClientProps.server?.userId, - }, - query: { - Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId', - Ids: query.id, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get playlist detail'); - } - - return jfNormalize.playlist(res.body, apiClientProps.server); -}; - -const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise => { - const { query, apiClientProps } = args; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - const res = await jfApiClient(apiClientProps).getPlaylistSongList({ - params: { - id: query.id, - }, - query: { - Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId', - IncludeItemTypes: 'Audio', - Limit: query.limit, - SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined, - SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined, - StartIndex: query.startIndex, - UserId: apiClientProps.server?.userId, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get playlist song list'); - } - - return { - items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), - startIndex: query.startIndex, - totalRecordCount: res.body.TotalRecordCount, - }; -}; - -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: query.limit, - MediaTypes: 'Audio', - Recursive: true, - SearchTerm: query.searchTerm, - SortBy: playlistListSortMap.jellyfin[query.sortBy], - SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get playlist list'); - } - - return { - items: res.body.Items.map((item) => jfNormalize.playlist(item, apiClientProps.server)), - startIndex: 0, - totalRecordCount: res.body.TotalRecordCount, - }; -}; - -const createPlaylist = async (args: CreatePlaylistArgs): Promise => { - const { body, apiClientProps } = args; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - const res = await jfApiClient(apiClientProps).createPlaylist({ - body: { - IsPublic: body.public, - MediaType: 'Audio', - Name: body.name, - UserId: apiClientProps.server.userId, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to create playlist'); - } - - return { - id: res.body.Id, - }; -}; - -const updatePlaylist = async (args: UpdatePlaylistArgs): Promise => { - const { query, body, apiClientProps } = args; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - const res = await jfApiClient(apiClientProps).updatePlaylist({ - body: { - Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [], - IsPublic: body.public, - MediaType: 'Audio', - Name: body.name, - PremiereDate: null, - ProviderIds: {}, - Tags: [], - UserId: apiClientProps.server?.userId, // Required - }, - params: { - id: query.id, - }, - }); - - if (res.status !== 204) { - throw new Error('Failed to update playlist'); - } - - return null; -}; - -const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await jfApiClient(apiClientProps).deletePlaylist({ - body: null, - params: { - id: query.id, - }, - }); - - if (res.status !== 204) { - throw new Error('Failed to delete playlist'); - } - - return null; -}; - -const createFavorite = async (args: FavoriteArgs): Promise => { - const { query, apiClientProps } = args; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - for (const id of query.id) { - await jfApiClient(apiClientProps).createFavorite({ - body: {}, - params: { - id, - userId: apiClientProps.server?.userId, - }, - }); - } - - return null; -}; - -const deleteFavorite = async (args: FavoriteArgs): Promise => { - const { query, apiClientProps } = args; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - for (const id of query.id) { - await jfApiClient(apiClientProps).removeFavorite({ - body: {}, - params: { - id, - userId: apiClientProps.server?.userId, - }, - }); - } - - return null; -}; - -const scrobble = async (args: ScrobbleArgs): Promise => { - const { query, apiClientProps } = args; - - const position = query.position && Math.round(query.position); - - if (query.submission) { - // Checked by jellyfin-plugin-lastfm for whether or not to send the "finished" scrobble (uses PositionTicks) - jfApiClient(apiClientProps).scrobbleStopped({ - body: { - IsPaused: true, - ItemId: query.id, - PositionTicks: position, - }, - }); return null; - } + }, + authenticate: async (url, body) => { + const cleanServerUrl = url.replace(/\/$/, ''); - if (query.event === 'start') { - jfApiClient(apiClientProps).scrobblePlaying({ + const res = await jfApiClient({ server: null, url: cleanServerUrl }).authenticate({ body: { - ItemId: query.id, - PositionTicks: position, - }, - }); - - return null; - } - - if (query.event === 'pause') { - jfApiClient(apiClientProps).scrobbleProgress({ - body: { - EventName: query.event, - IsPaused: true, - ItemId: query.id, - PositionTicks: position, - }, - }); - - return null; - } - - if (query.event === 'unpause') { - jfApiClient(apiClientProps).scrobbleProgress({ - body: { - EventName: query.event, - IsPaused: false, - ItemId: query.id, - PositionTicks: position, - }, - }); - - return null; - } - - jfApiClient(apiClientProps).scrobbleProgress({ - body: { - ItemId: query.id, - PositionTicks: position, - }, - }); - - return null; -}; - -const search = async (args: SearchArgs): Promise => { - const { query, apiClientProps } = args; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - let albums: z.infer['Items'] = []; - let albumArtists: z.infer['Items'] = []; - let songs: z.infer['Items'] = []; - - if (query.albumLimit) { - const res = await jfApiClient(apiClientProps).getAlbumList({ - params: { - userId: apiClientProps.server?.userId, - }, - query: { - EnableTotalRecordCount: true, - ImageTypeLimit: 1, - IncludeItemTypes: 'MusicAlbum', - Limit: query.albumLimit, - Recursive: true, - SearchTerm: query.query, - SortBy: 'SortName', - SortOrder: 'Ascending', - StartIndex: query.albumStartIndex || 0, + Pw: body.password, + Username: body.username, }, }); if (res.status !== 200) { - throw new Error('Failed to get album list'); + throw new Error('Failed to authenticate'); } - albums = res.body.Items; - } + return { + credential: res.body.AccessToken, + userId: res.body.User.Id, + username: res.body.User.Name, + }; + }, + createFavorite: async (args) => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + for (const id of query.id) { + await jfApiClient(apiClientProps).createFavorite({ + body: {}, + params: { + id, + userId: apiClientProps.server?.userId, + }, + }); + } + + return null; + }, + createPlaylist: async (args) => { + const { body, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).createPlaylist({ + body: { + IsPublic: body.public, + MediaType: 'Audio', + Name: body.name, + UserId: apiClientProps.server.userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to create playlist'); + } + + return { + id: res.body.Id, + }; + }, + deleteFavorite: async (args) => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + for (const id of query.id) { + await jfApiClient(apiClientProps).removeFavorite({ + body: {}, + params: { + id, + userId: apiClientProps.server?.userId, + }, + }); + } + + return null; + }, + deletePlaylist: async (args) => { + const { query, apiClientProps } = args; + + const res = await jfApiClient(apiClientProps).deletePlaylist({ + body: null, + params: { + id: query.id, + }, + }); + + if (res.status !== 204) { + throw new Error('Failed to delete playlist'); + } + + return null; + }, + getAlbumArtistDetail: async (args) => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getAlbumArtistDetail({ + params: { + id: query.id, + userId: apiClientProps.server?.userId, + }, + query: { + Fields: 'Genres, Overview', + }, + }); + + const similarArtistsRes = await jfApiClient(apiClientProps).getSimilarArtistList({ + params: { + id: query.id, + }, + query: { + Limit: 10, + }, + }); + + if (res.status !== 200 || similarArtistsRes.status !== 200) { + throw new Error('Failed to get album artist detail'); + } + + return jfNormalize.albumArtist( + { ...res.body, similarArtists: similarArtistsRes.body }, + apiClientProps.server, + ); + }, + getAlbumArtistList: async (args) => { + const { query, apiClientProps } = args; - if (query.albumArtistLimit) { const res = await jfApiClient(apiClientProps).getAlbumArtistList({ query: { - EnableTotalRecordCount: true, Fields: 'Genres, DateCreated, ExternalUrls, Overview', ImageTypeLimit: 1, - IncludeArtists: true, - Limit: query.albumArtistLimit, + Limit: query.limit, + ParentId: query.musicFolderId, Recursive: true, - SearchTerm: query.query, - StartIndex: query.albumArtistStartIndex || 0, - UserId: apiClientProps.server?.userId, + SearchTerm: query.searchTerm, + SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: query.startIndex, + UserId: apiClientProps.server?.userId || undefined, }, }); @@ -831,25 +221,469 @@ const search = async (args: SearchArgs): Promise => { throw new Error('Failed to get album artist list'); } - albumArtists = res.body.Items; - } + return { + items: res.body.Items.map((item) => + jfNormalize.albumArtist(item, apiClientProps.server), + ), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; + }, + getAlbumArtistListCount: async ({ apiClientProps, query }) => + JellyfinController.getAlbumArtistList({ + apiClientProps, + query: { ...query, limit: 1, startIndex: 0 }, + }).then((result) => result!.totalRecordCount!), + getAlbumDetail: async (args) => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getAlbumDetail({ + params: { + id: query.id, + userId: apiClientProps.server.userId, + }, + query: { + Fields: 'Genres, DateCreated, ChildCount', + }, + }); + + const songsRes = await jfApiClient(apiClientProps).getSongList({ + params: { + userId: apiClientProps.server.userId, + }, + query: { + Fields: 'Genres, DateCreated, MediaSources, ParentId', + IncludeItemTypes: 'Audio', + ParentId: query.id, + SortBy: 'ParentIndexNumber,IndexNumber,SortName', + }, + }); + + if (res.status !== 200 || songsRes.status !== 200) { + throw new Error('Failed to get album detail'); + } + + return jfNormalize.album( + { ...res.body, Songs: songsRes.body.Items }, + apiClientProps.server, + ); + }, + getAlbumList: async (args) => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const yearsGroup = []; + if (query.minYear && query.maxYear) { + for (let i = Number(query.minYear); i <= Number(query.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.compilation ? query.artistIds?.[0] : undefined, + GenreIds: query.genres ? query.genres.join(',') : undefined, + IncludeItemTypes: 'MusicAlbum', + IsFavorite: query.favorite, + Limit: query.limit, + ParentId: query.musicFolderId, + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: query.startIndex, + ...query._custom?.jellyfin, + Years: yearsFilter, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list'); + } + + return { + items: res.body.Items.map((item) => jfNormalize.album(item, apiClientProps.server)), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; + }, + getAlbumListCount: async ({ apiClientProps, query }) => + JellyfinController.getAlbumList({ + apiClientProps, + query: { ...query, limit: 1, startIndex: 0 }, + }).then((result) => result!.totalRecordCount!), + getDownloadUrl: (args) => { + const { apiClientProps, query } = args; + + return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`; + }, + getGenreList: async (args) => { + const { apiClientProps, query } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getGenreList({ + query: { + Fields: 'ItemCounts', + ParentId: query?.musicFolderId, + Recursive: true, + SearchTerm: query?.searchTerm, + SortBy: genreListSortMap.jellyfin[query.sortBy] || 'SortName', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: query.startIndex, + UserId: apiClientProps.server?.userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get genre list'); + } + + return { + items: res.body.Items.map((item) => jfNormalize.genre(item, apiClientProps.server)), + startIndex: query.startIndex || 0, + totalRecordCount: res.body?.TotalRecordCount || 0, + }; + }, + getLyrics: async (args) => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getSongLyrics({ + params: { + id: query.songId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get lyrics'); + } + + if (res.body.Lyrics.length > 0 && res.body.Lyrics[0].Start === undefined) { + return res.body.Lyrics.map((lyric) => lyric.Text).join('\n'); + } + + return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]); + }, + getMusicFolderList: async (args) => { + const { apiClientProps } = args; + const userId = apiClientProps.server?.userId; + + if (!userId) throw new Error('No userId found'); + + const res = await jfApiClient(apiClientProps).getMusicFolderList({ + params: { + userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get genre list'); + } + + const musicFolders = res.body.Items.filter( + (folder) => folder.CollectionType === jfType._enum.collection.MUSIC, + ); + + return { + items: musicFolders.map(jfNormalize.musicFolder), + startIndex: 0, + totalRecordCount: musicFolders?.length || 0, + }; + }, + getPlaylistDetail: async (args) => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getPlaylistDetail({ + params: { + id: query.id, + userId: apiClientProps.server?.userId, + }, + query: { + Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId', + Ids: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist detail'); + } + + return jfNormalize.playlist(res.body, apiClientProps.server); + }, + getPlaylistList: async (args) => { + 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: query.limit, + MediaTypes: 'Audio', + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: playlistListSortMap.jellyfin[query.sortBy], + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: query.startIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist list'); + } + + return { + items: res.body.Items.map((item) => jfNormalize.playlist(item, apiClientProps.server)), + startIndex: 0, + totalRecordCount: res.body.TotalRecordCount, + }; + }, + getPlaylistListCount: async ({ apiClientProps, query }) => + JellyfinController.getPlaylistList({ + apiClientProps, + query: { ...query, limit: 1, startIndex: 0 }, + }).then((result) => result!.totalRecordCount!), + getPlaylistSongList: async (args) => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getPlaylistSongList({ + params: { + id: query.id, + }, + query: { + Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId', + IncludeItemTypes: 'Audio', + Limit: query.limit, + SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined, + SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined, + StartIndex: query.startIndex, + UserId: apiClientProps.server?.userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist song list'); + } + + return { + items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; + }, + getRandomSongList: async (args) => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const yearsGroup = []; + if (query.minYear && query.maxYear) { + for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) { + yearsGroup.push(String(i)); + } + } + + const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined; - if (query.songLimit) { const res = await jfApiClient(apiClientProps).getSongList({ params: { userId: apiClientProps.server?.userId, }, query: { - EnableTotalRecordCount: true, Fields: 'Genres, DateCreated, MediaSources, ParentId', + GenreIds: query.genre ? query.genre : undefined, IncludeItemTypes: 'Audio', - Limit: query.songLimit, + IsPlayed: + query.played === Played.Never + ? false + : query.played === Played.Played + ? true + : undefined, + Limit: query.limit, + ParentId: query.musicFolderId, Recursive: true, - SearchTerm: query.query, - SortBy: 'Album,SortName', - SortOrder: 'Ascending', - StartIndex: query.songStartIndex || 0, - UserId: apiClientProps.server?.userId, + SortBy: JFSongListSort.RANDOM, + SortOrder: JFSortOrder.ASC, + StartIndex: 0, + Years: yearsFilter, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get random songs'); + } + + return { + items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + startIndex: 0, + totalRecordCount: res.body.Items.length || 0, + }; + }, + getServerInfo: async (args) => { + const { apiClientProps } = args; + + const res = await jfApiClient(apiClientProps).getServerInfo(); + + if (res.status !== 200) { + throw new Error('Failed to get server info'); + } + + const features = getFeatures(VERSION_INFO, res.body.Version); + + return { + features, + id: apiClientProps.server?.id, + version: res.body.Version, + }; + }, + getSimilarSongs: async (args) => { + const { apiClientProps, query } = args; + + // Prefer getSimilarSongs, where possible. Fallback to InstantMix + // where no similar songs were found. + const res = await jfApiClient(apiClientProps).getSimilarSongs({ + params: { + itemId: query.songId, + }, + query: { + Fields: 'Genres, DateCreated, MediaSources, ParentId', + Limit: query.count, + UserId: apiClientProps.server?.userId || undefined, + }, + }); + + if (res.status === 200 && res.body.Items.length) { + const results = res.body.Items.reduce((acc, song) => { + if (song.Id !== query.songId) { + acc.push(jfNormalize.song(song, apiClientProps.server, '')); + } + + return acc; + }, []); + + if (results.length > 0) { + return results; + } + } + + const mix = await jfApiClient(apiClientProps).getInstantMix({ + params: { + itemId: query.songId, + }, + query: { + Fields: 'Genres, DateCreated, MediaSources, ParentId', + Limit: query.count, + UserId: apiClientProps.server?.userId || undefined, + }, + }); + + if (mix.status !== 200) { + throw new Error('Failed to get similar songs'); + } + + return mix.body.Items.reduce((acc, song) => { + if (song.Id !== query.songId) { + acc.push(jfNormalize.song(song, apiClientProps.server, '')); + } + + return acc; + }, []); + }, + getSongDetail: async (args) => { + const { query, apiClientProps } = args; + + const res = await jfApiClient(apiClientProps).getSongDetail({ + params: { + id: query.id, + userId: apiClientProps.server?.userId ?? '', + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song detail'); + } + + return jfNormalize.song(res.body, apiClientProps.server, ''); + }, + getSongList: async (args) => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const yearsGroup = []; + if (query.minYear && query.maxYear) { + for (let i = Number(query.minYear); i <= Number(query.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', + GenreIds: query.genreIds?.join(','), + IncludeItemTypes: 'Audio', + IsFavorite: query.favorite, + Limit: query.limit, + ParentId: query.musicFolderId, + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: query.startIndex, + ...query._custom?.jellyfin, + Years: yearsFilter, }, }); @@ -857,255 +691,330 @@ const search = async (args: SearchArgs): Promise => { throw new Error('Failed to get song list'); } - songs = res.body.Items; - } + let items: z.infer[]; - return { - albumArtists: albumArtists.map((item) => - jfNormalize.albumArtist(item, apiClientProps.server), - ), - albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)), - songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')), - }; -}; + // Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622 + // If the Album ID filter is passed, Jellyfin will search for + // 1. the matching album id + // 2. An album with the name of the album. + // It is this second condition causing issues, + if (query.albumIds) { + const albumIdSet = new Set(query.albumIds); + items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId!)); -const getRandomSongList = async (args: RandomSongListArgs): Promise => { - const { query, apiClientProps } = args; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - const yearsGroup = []; - if (query.minYear && query.maxYear) { - for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) { - yearsGroup.push(String(i)); + if (items.length < res.body.Items.length) { + res.body.TotalRecordCount -= res.body.Items.length - items.length; + } + } else { + items = res.body.Items; } - } - const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined; + return { + items: items.map((item) => + jfNormalize.song(item, apiClientProps.server, '', query.imageSize), + ), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; + }, + getSongListCount: async ({ apiClientProps, query }) => + JellyfinController.getSongList({ + apiClientProps, + query: { ...query, limit: 1, startIndex: 0 }, + }).then((result) => result!.totalRecordCount!), + getTopSongs: async (args) => { + const { apiClientProps, query } = args; - const res = await jfApiClient(apiClientProps).getSongList({ - params: { - userId: apiClientProps.server?.userId, - }, - query: { - Fields: 'Genres, DateCreated, MediaSources, ParentId', - GenreIds: query.genre ? query.genre : undefined, - IncludeItemTypes: 'Audio', - IsPlayed: - query.played === Played.Never - ? false - : query.played === Played.Played - ? true - : undefined, - Limit: query.limit, - ParentId: query.musicFolderId, - Recursive: true, - SortBy: JFSongListSort.RANDOM, - SortOrder: JFSortOrder.ASC, - StartIndex: 0, - Years: yearsFilter, - }, - }); + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - if (res.status !== 200) { - throw new Error('Failed to get random songs'); - } + const res = await jfApiClient(apiClientProps).getTopSongsList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + ArtistIds: query.artistId, + Fields: 'Genres, DateCreated, MediaSources, ParentId', + IncludeItemTypes: 'Audio', + Limit: query.limit, + Recursive: true, + SortBy: 'PlayCount,SortName', + SortOrder: 'Descending', + UserId: apiClientProps.server?.userId, + }, + }); - return { - items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), - startIndex: 0, - totalRecordCount: res.body.Items.length || 0, - }; -}; + if (res.status !== 200) { + throw new Error('Failed to get top song list'); + } -const getLyrics = async (args: LyricsArgs): Promise => { - const { query, apiClientProps } = args; + return { + items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + startIndex: 0, + totalRecordCount: res.body.TotalRecordCount, + }; + }, + getTranscodingUrl: (args) => { + const { base, format, bitrate } = args.query; + let url = base.replace('transcodingProtocol=hls', 'transcodingProtocol=http'); + if (format) { + url = url.replace('audioCodec=aac', `audioCodec=${format}`); + url = url.replace('transcodingContainer=ts', `transcodingContainer=${format}`); + } + if (bitrate !== undefined) { + url += `&maxStreamingBitrate=${bitrate * 1000}`; + } - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } + return url; + }, + movePlaylistItem: async (args) => { + const { apiClientProps, query } = args; - const res = await jfApiClient(apiClientProps).getSongLyrics({ - params: { - id: query.songId, - }, - }); + const res = await jfApiClient(apiClientProps).movePlaylistItem({ + body: null, + params: { + itemId: query.trackId, + newIdx: query.endingIndex.toString(), + playlistId: query.playlistId, + }, + }); - if (res.status !== 200) { - throw new Error('Failed to get lyrics'); - } + if (res.status !== 204) { + throw new Error('Failed to move item in playlist'); + } + }, + removeFromPlaylist: async (args) => { + const { query, apiClientProps } = args; - if (res.body.Lyrics.length > 0 && res.body.Lyrics[0].Start === undefined) { - return res.body.Lyrics.map((lyric) => lyric.Text).join('\n'); - } + const chunks = chunk(query.songId, MAX_ITEMS_PER_PLAYLIST_ADD); - return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]); -}; + for (const chunk of chunks) { + const res = await jfApiClient(apiClientProps).removeFromPlaylist({ + body: null, + params: { + id: query.id, + }, + query: { + EntryIds: chunk.join(','), + }, + }); -const getSongDetail = async (args: SongDetailArgs): Promise => { - const { query, apiClientProps } = args; + if (res.status !== 204) { + throw new Error('Failed to remove from playlist'); + } + } - const res = await jfApiClient(apiClientProps).getSongDetail({ - params: { - id: query.id, - userId: apiClientProps.server?.userId ?? '', - }, - }); + return null; + }, + scrobble: async (args) => { + const { query, apiClientProps } = args; - if (res.status !== 200) { - throw new Error('Failed to get song detail'); - } + const position = query.position && Math.round(query.position); - return jfNormalize.song(res.body, apiClientProps.server, ''); -}; + if (query.submission) { + // Checked by jellyfin-plugin-lastfm for whether or not to send the "finished" scrobble (uses PositionTicks) + jfApiClient(apiClientProps).scrobbleStopped({ + body: { + IsPaused: true, + ItemId: query.id, + PositionTicks: position, + }, + }); -const VERSION_INFO: VersionInfo = [ - [ - '10.9.0', - { [ServerFeature.LYRICS_SINGLE_STRUCTURED]: [1], [ServerFeature.PUBLIC_PLAYLIST]: [1] }, - ], -]; + return null; + } -const getServerInfo = async (args: ServerInfoArgs): Promise => { - const { apiClientProps } = args; + if (query.event === 'start') { + jfApiClient(apiClientProps).scrobblePlaying({ + body: { + ItemId: query.id, + PositionTicks: position, + }, + }); - const res = await jfApiClient(apiClientProps).getServerInfo(); + return null; + } - if (res.status !== 200) { - throw new Error('Failed to get server info'); - } + if (query.event === 'pause') { + jfApiClient(apiClientProps).scrobbleProgress({ + body: { + EventName: query.event, + IsPaused: true, + ItemId: query.id, + PositionTicks: position, + }, + }); - const features = getFeatures(VERSION_INFO, res.body.Version); + return null; + } - return { - features, - id: apiClientProps.server?.id, - version: res.body.Version, - }; -}; + if (query.event === 'unpause') { + jfApiClient(apiClientProps).scrobbleProgress({ + body: { + EventName: query.event, + IsPaused: false, + ItemId: query.id, + PositionTicks: position, + }, + }); -const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { - const { apiClientProps, query } = args; + return null; + } - // Prefer getSimilarSongs, where possible. Fallback to InstantMix - // where no similar songs were found. - const res = await jfApiClient(apiClientProps).getSimilarSongs({ - params: { - itemId: query.songId, - }, - query: { - Fields: 'Genres, DateCreated, MediaSources, ParentId', - Limit: query.count, - UserId: apiClientProps.server?.userId || undefined, - }, - }); + jfApiClient(apiClientProps).scrobbleProgress({ + body: { + ItemId: query.id, + PositionTicks: position, + }, + }); - if (res.status === 200 && res.body.Items.length) { - const results = res.body.Items.reduce((acc, song) => { - if (song.Id !== query.songId) { - acc.push(jfNormalize.song(song, apiClientProps.server, '')); + return null; + }, + search: async (args) => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + let albums: z.infer['Items'] = []; + let albumArtists: z.infer['Items'] = []; + let songs: z.infer['Items'] = []; + + if (query.albumLimit) { + const res = await jfApiClient(apiClientProps).getAlbumList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + EnableTotalRecordCount: true, + ImageTypeLimit: 1, + IncludeItemTypes: 'MusicAlbum', + Limit: query.albumLimit, + Recursive: true, + SearchTerm: query.query, + SortBy: 'SortName', + SortOrder: 'Ascending', + StartIndex: query.albumStartIndex || 0, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list'); } - return acc; - }, []); - - if (results.length > 0) { - return results; - } - } - - const mix = await jfApiClient(apiClientProps).getInstantMix({ - params: { - itemId: query.songId, - }, - query: { - Fields: 'Genres, DateCreated, MediaSources, ParentId', - Limit: query.count, - UserId: apiClientProps.server?.userId || undefined, - }, - }); - - if (mix.status !== 200) { - throw new Error('Failed to get similar songs'); - } - - return mix.body.Items.reduce((acc, song) => { - if (song.Id !== query.songId) { - acc.push(jfNormalize.song(song, apiClientProps.server, '')); + albums = res.body.Items; } - return acc; - }, []); + if (query.albumArtistLimit) { + const res = await jfApiClient(apiClientProps).getAlbumArtistList({ + query: { + EnableTotalRecordCount: true, + Fields: 'Genres, DateCreated, ExternalUrls, Overview', + ImageTypeLimit: 1, + IncludeArtists: true, + Limit: query.albumArtistLimit, + Recursive: true, + SearchTerm: query.query, + StartIndex: query.albumArtistStartIndex || 0, + UserId: apiClientProps.server?.userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album artist list'); + } + + albumArtists = res.body.Items; + } + + if (query.songLimit) { + const res = await jfApiClient(apiClientProps).getSongList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + EnableTotalRecordCount: true, + Fields: 'Genres, DateCreated, MediaSources, ParentId', + IncludeItemTypes: 'Audio', + Limit: query.songLimit, + Recursive: true, + SearchTerm: query.query, + SortBy: 'Album,SortName', + SortOrder: 'Ascending', + StartIndex: query.songStartIndex || 0, + UserId: apiClientProps.server?.userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list'); + } + + songs = res.body.Items; + } + + return { + albumArtists: albumArtists.map((item) => + jfNormalize.albumArtist(item, apiClientProps.server), + ), + albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)), + songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + }; + }, + updatePlaylist: async (args) => { + const { query, body, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).updatePlaylist({ + body: { + Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [], + IsPublic: body.public, + MediaType: 'Audio', + Name: body.name, + PremiereDate: null, + ProviderIds: {}, + Tags: [], + UserId: apiClientProps.server?.userId, // Required + }, + params: { + id: query.id, + }, + }); + + if (res.status !== 204) { + throw new Error('Failed to update playlist'); + } + + return null; + }, }; -const movePlaylistItem = async (args: MoveItemArgs): Promise => { - const { apiClientProps, query } = args; +// const getArtistList = async (args: ArtistListArgs): Promise => { +// const { query, apiClientProps } = args; - const res = await jfApiClient(apiClientProps).movePlaylistItem({ - body: null, - params: { - itemId: query.trackId, - newIdx: query.endingIndex.toString(), - playlistId: query.playlistId, - }, - }); +// const res = await jfApiClient(apiClientProps).getAlbumArtistList({ +// query: { +// Limit: query.limit, +// ParentId: query.musicFolderId, +// Recursive: true, +// SortBy: artistListSortMap.jellyfin[query.sortBy] || 'SortName,Name', +// SortOrder: sortOrderMap.jellyfin[query.sortOrder], +// StartIndex: query.startIndex, +// }, +// }); - if (res.status !== 204) { - throw new Error('Failed to move item in playlist'); - } -}; +// if (res.status !== 200) { +// throw new Error('Failed to get artist list'); +// } -const getDownloadUrl = (args: DownloadArgs) => { - const { apiClientProps, query } = args; - - return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`; -}; - -const getTranscodingUrl = (args: TranscodingArgs) => { - const { base, format, bitrate } = args.query; - let url = base.replace('transcodingProtocol=hls', 'transcodingProtocol=http'); - if (format) { - url = url.replace('audioCodec=aac', `audioCodec=${format}`); - url = url.replace('transcodingContainer=ts', `transcodingContainer=${format}`); - } - if (bitrate !== undefined) { - url += `&maxStreamingBitrate=${bitrate * 1000}`; - } - - return url; -}; - -export const jfController = { - addToPlaylist, - authenticate, - createFavorite, - createPlaylist, - deleteFavorite, - deletePlaylist, - getAlbumArtistDetail, - getAlbumArtistList, - getAlbumDetail, - getAlbumList, - getArtistList, - getDownloadUrl, - getGenreList, - getLyrics, - getMusicFolderList, - getPlaylistDetail, - getPlaylistList, - getPlaylistSongList, - getRandomSongList, - getServerInfo, - getSimilarSongs, - getSongDetail, - getSongList, - getTopSongList, - getTranscodingUrl, - movePlaylistItem, - removeFromPlaylist, - scrobble, - search, - updatePlaylist, -}; +// return { +// items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)), +// startIndex: query.startIndex, +// totalRecordCount: res.body.TotalRecordCount, +// }; +// }; diff --git a/src/renderer/api/navidrome.types.ts b/src/renderer/api/navidrome.types.ts index 35bbb9fc..0b34b4ad 100644 --- a/src/renderer/api/navidrome.types.ts +++ b/src/renderer/api/navidrome.types.ts @@ -237,7 +237,7 @@ export enum NDSongListSort { CHANNELS = 'channels', COMMENT = 'comment', DURATION = 'duration', - FAVORITED = 'starred', + FAVORITED = 'starred_at', GENRE = 'genre', ID = 'id', PLAY_COUNT = 'playCount', diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 6b2fd353..641e0cde 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -3,656 +3,607 @@ 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 { - AlbumArtistDetailArgs, - AlbumArtistDetailResponse, - AddToPlaylistArgs, - AddToPlaylistResponse, - CreatePlaylistResponse, - CreatePlaylistArgs, - DeletePlaylistArgs, - DeletePlaylistResponse, - AlbumArtistListResponse, - AlbumArtistListArgs, albumArtistListSortMap, sortOrderMap, AuthenticationResponse, - UserListResponse, - UserListArgs, userListSortMap, - GenreListArgs, - GenreListResponse, - AlbumDetailResponse, - AlbumDetailArgs, - AlbumListArgs, albumListSortMap, - AlbumListResponse, - SongListResponse, - SongListArgs, songListSortMap, - SongDetailResponse, - SongDetailArgs, - UpdatePlaylistArgs, - UpdatePlaylistResponse, - PlaylistListResponse, - PlaylistDetailArgs, - PlaylistListArgs, playlistListSortMap, - PlaylistDetailResponse, PlaylistSongListArgs, PlaylistSongListResponse, - RemoveFromPlaylistResponse, - RemoveFromPlaylistArgs, genreListSortMap, - ServerInfo, - ServerInfoArgs, - ShareItemArgs, - ShareItemResponse, - SimilarSongsArgs, Song, - MoveItemArgs, + ControllerEndpoint, } from '../types'; import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils'; import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types'; import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types'; import { NDSongListSort } from '/@/renderer/api/navidrome.types'; import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize'; - -const authenticate = async ( - url: string, - body: { password: string; username: string }, -): Promise => { - const cleanServerUrl = url.replace(/\/$/, ''); - - const res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({ - body: { - password: body.password, - username: body.username, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to authenticate'); - } - - return { - credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`, - ndCredential: res.body.data.token, - userId: res.body.data.id, - username: res.body.data.username, - }; -}; - -const getUserList = async (args: UserListArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).getUserList({ - query: { - _end: query.startIndex + (query.limit || 0), - _order: sortOrderMap.navidrome[query.sortOrder], - _sort: userListSortMap.navidrome[query.sortBy], - _start: query.startIndex, - ...query._custom?.navidrome, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get user list'); - } - - return { - items: res.body.data.map((user) => ndNormalize.user(user)), - startIndex: query?.startIndex || 0, - totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), - }; -}; - -const getGenreList = async (args: GenreListArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).getGenreList({ - query: { - _end: query.startIndex + (query.limit || 0), - _order: sortOrderMap.navidrome[query.sortOrder], - _sort: genreListSortMap.navidrome[query.sortBy], - _start: query.startIndex, - name: query.searchTerm, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get genre list'); - } - - return { - items: res.body.data.map((genre) => ndNormalize.genre(genre)), - startIndex: query.startIndex || 0, - totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), - }; -}; - -const getAlbumArtistDetail = async ( - args: AlbumArtistDetailArgs, -): Promise => { - const { query, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({ - params: { - id: query.id, - }, - }); - - const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({ - query: { - count: 10, - id: query.id, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get album artist detail'); - } - - if (!apiClientProps.server) { - throw new Error('Server is required'); - } - - // Prefer images from getArtistInfo first (which should be proxied) - // Prioritize large > medium > small - return ndNormalize.albumArtist( - { - ...res.body.data, - ...(artistInfoRes.status === 200 && { - largeImageUrl: - artistInfoRes.body.artistInfo.largeImageUrl || - artistInfoRes.body.artistInfo.mediumImageUrl || - artistInfoRes.body.artistInfo.smallImageUrl || - res.body.data.largeImageUrl, - similarArtists: artistInfoRes.body.artistInfo.similarArtist, - }), - }, - apiClientProps.server, - ); -}; - -const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).getAlbumArtistList({ - query: { - _end: query.startIndex + (query.limit || 0), - _order: sortOrderMap.navidrome[query.sortOrder], - _sort: albumArtistListSortMap.navidrome[query.sortBy], - _start: query.startIndex, - name: query.searchTerm, - ...query._custom?.navidrome, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get album artist list'); - } - - return { - items: res.body.data.map((albumArtist) => - // Navidrome native API will return only external URL small/medium/large - // image URL. Set large image to undefined to force `albumArtist` to use - // /rest/getCoverArt.view?id=ar-... - ndNormalize.albumArtist( - { - ...albumArtist, - largeImageUrl: undefined, - }, - apiClientProps.server, - ), - ), - startIndex: query.startIndex, - totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), - }; -}; - -const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { - const { query, apiClientProps } = args; - - const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({ - params: { - id: query.id, - }, - }); - - const songsData = await ndApiClient(apiClientProps).getSongList({ - query: { - _end: 0, - _order: 'ASC', - _sort: NDSongListSort.ALBUM, - _start: 0, - album_id: [query.id], - }, - }); - - if (albumRes.status !== 200 || songsData.status !== 200) { - throw new Error('Failed to get album detail'); - } - - return ndNormalize.album( - { ...albumRes.body.data, songs: songsData.body.data }, - apiClientProps.server, - ); -}; - -const getAlbumList = async (args: AlbumListArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).getAlbumList({ - query: { - _end: query.startIndex + (query.limit || 0), - _order: sortOrderMap.navidrome[query.sortOrder], - _sort: albumListSortMap.navidrome[query.sortBy], - _start: query.startIndex, - artist_id: query.artistIds?.[0], - name: query.searchTerm, - ...query._custom?.navidrome, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get album list'); - } - - return { - items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)), - startIndex: query?.startIndex || 0, - totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), - }; -}; - -const getSongList = async (args: SongListArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).getSongList({ - query: { - _end: query.startIndex + (query.limit || -1), - _order: sortOrderMap.navidrome[query.sortOrder], - _sort: songListSortMap.navidrome[query.sortBy], - _start: query.startIndex, - 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'); - } - - return { - items: res.body.data.map((song) => - ndNormalize.song(song, apiClientProps.server, '', query.imageSize), - ), - startIndex: query?.startIndex || 0, - totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), - }; -}; - -const getSongDetail = async (args: SongDetailArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).getSongDetail({ - params: { - id: query.id, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get song detail'); - } - - return ndNormalize.song(res.body.data, apiClientProps.server, ''); -}; - -const createPlaylist = async (args: CreatePlaylistArgs): Promise => { - const { body, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).createPlaylist({ - body: { - comment: body.comment, - name: body.name, - public: body.public, - rules: body._custom?.navidrome?.rules, - sync: body._custom?.navidrome?.sync, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to create playlist'); - } - - return { - id: res.body.data.id, - }; -}; - -const updatePlaylist = async (args: UpdatePlaylistArgs): Promise => { - const { query, body, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).updatePlaylist({ - body: { - comment: body.comment || '', - name: body.name, - public: body?.public || false, - rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined, - sync: body._custom?.navidrome?.sync || undefined, - }, - params: { - id: query.id, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to update playlist'); - } - - return null; -}; - -const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).deletePlaylist({ - body: null, - params: { - id: query.id, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to delete playlist'); - } - - return null; -}; - -const getPlaylistList = async (args: PlaylistListArgs): Promise => { - const { query, apiClientProps } = args; - const customQuery = query._custom?.navidrome; - - // Smart playlists only became available in 0.48.0. Do not filter for previous versions - if ( - customQuery && - customQuery.smart !== undefined && - !hasFeature(apiClientProps.server, ServerFeature.PLAYLISTS_SMART) - ) { - customQuery.smart = undefined; - } - - const res = await ndApiClient(apiClientProps).getPlaylistList({ - query: { - _end: query.startIndex + (query.limit || 0), - _order: sortOrderMap.navidrome[query.sortOrder], - _sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined, - _start: query.startIndex, - q: query.searchTerm, - ...customQuery, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get playlist list'); - } - - return { - items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)), - startIndex: query?.startIndex || 0, - totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), - }; -}; - -const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).getPlaylistDetail({ - params: { - id: query.id, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get playlist detail'); - } - - return ndNormalize.playlist(res.body.data, apiClientProps.server); -}; - -const getPlaylistSongList = async ( - args: PlaylistSongListArgs, -): Promise => { - const { query, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).getPlaylistSongList({ - params: { - 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, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get playlist song list'); - } - - return { - items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')), - startIndex: query?.startIndex || 0, - totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), - }; -}; - -const addToPlaylist = async (args: AddToPlaylistArgs): Promise => { - const { body, query, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).addToPlaylist({ - body: { - ids: body.songId, - }, - params: { - id: query.id, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to add to playlist'); - } - - return null; -}; - -const removeFromPlaylist = async ( - args: RemoveFromPlaylistArgs, -): Promise => { - const { query, apiClientProps } = args; - - const res = await ndApiClient(apiClientProps).removeFromPlaylist({ - body: null, - params: { - id: query.id, - }, - query: { - id: query.songId, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to remove from playlist'); - } - - return null; -}; +import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; const VERSION_INFO: VersionInfo = [ ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }], ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }], ]; -const getServerInfo = async (args: ServerInfoArgs): Promise => { - const { apiClientProps } = args; +export const NavidromeController: ControllerEndpoint = { + addToPlaylist: async (args) => { + const { body, query, apiClientProps } = args; - // Navidrome will always populate serverVersion - const ping = await ssApiClient(apiClientProps).ping(); - - if (ping.status !== 200) { - throw new Error('Failed to ping server'); - } - - const navidromeFeatures: Record = getFeatures( - VERSION_INFO, - ping.body.serverVersion!, - ); - - if (ping.body.openSubsonic) { - const res = await ssApiClient(apiClientProps).getServerInfo(); + const res = await ndApiClient(apiClientProps).addToPlaylist({ + body: { + ids: body.songId, + }, + params: { + id: query.id, + }, + }); if (res.status !== 200) { - throw new Error('Failed to get server extensions'); + throw new Error('Failed to add to playlist'); } - // The type here isn't necessarily an array (even though it's supposed to be). This is - // an implementation detail of Navidrome 0.50. Do a type check to make sure it's actually - // an array, and not an empty object. - if (Array.isArray(res.body.openSubsonicExtensions)) { - for (const extension of res.body.openSubsonicExtensions) { - navidromeFeatures[extension.name] = extension.versions; + return null; + }, + authenticate: async (url, body): Promise => { + const cleanServerUrl = url.replace(/\/$/, ''); + + const res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({ + body: { + password: body.password, + username: body.username, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to authenticate'); + } + + return { + credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`, + ndCredential: res.body.data.token, + userId: res.body.data.id, + username: res.body.data.username, + }; + }, + createFavorite: SubsonicController.createFavorite, + createPlaylist: async (args) => { + const { body, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).createPlaylist({ + body: { + comment: body.comment, + name: body.name, + public: body.public, + rules: body._custom?.navidrome?.rules, + sync: body._custom?.navidrome?.sync, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to create playlist'); + } + + return { + id: res.body.data.id, + }; + }, + deleteFavorite: SubsonicController.deleteFavorite, + deletePlaylist: async (args) => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).deletePlaylist({ + body: null, + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to delete playlist'); + } + + return null; + }, + getAlbumArtistDetail: async (args) => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({ + params: { + id: query.id, + }, + }); + + const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({ + query: { + count: 10, + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album artist detail'); + } + + if (!apiClientProps.server) { + throw new Error('Server is required'); + } + + // Prefer images from getArtistInfo first (which should be proxied) + // Prioritize large > medium > small + return ndNormalize.albumArtist( + { + ...res.body.data, + ...(artistInfoRes.status === 200 && { + largeImageUrl: + artistInfoRes.body.artistInfo.largeImageUrl || + artistInfoRes.body.artistInfo.mediumImageUrl || + artistInfoRes.body.artistInfo.smallImageUrl || + res.body.data.largeImageUrl, + similarArtists: artistInfoRes.body.artistInfo.similarArtist, + }), + }, + apiClientProps.server, + ); + }, + getAlbumArtistList: async (args) => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getAlbumArtistList({ + query: { + _end: query.startIndex + (query.limit || 0), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: albumArtistListSortMap.navidrome[query.sortBy], + _start: query.startIndex, + name: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album artist list'); + } + + return { + items: res.body.data.map((albumArtist) => + // Navidrome native API will return only external URL small/medium/large + // image URL. Set large image to undefined to force `albumArtist` to use + // /rest/getCoverArt.view?id=ar-... + ndNormalize.albumArtist( + { + ...albumArtist, + largeImageUrl: undefined, + }, + apiClientProps.server, + ), + ), + startIndex: query.startIndex, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; + }, + getAlbumArtistListCount: async ({ apiClientProps, query }) => + NavidromeController.getAlbumArtistList({ + apiClientProps, + query: { ...query, limit: 1, startIndex: 0 }, + }).then((result) => result!.totalRecordCount!), + getAlbumDetail: async (args) => { + const { query, apiClientProps } = args; + + const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({ + params: { + id: query.id, + }, + }); + + const songsData = await ndApiClient(apiClientProps).getSongList({ + query: { + _end: 0, + _order: 'ASC', + _sort: NDSongListSort.ALBUM, + _start: 0, + album_id: [query.id], + }, + }); + + if (albumRes.status !== 200 || songsData.status !== 200) { + throw new Error('Failed to get album detail'); + } + + return ndNormalize.album( + { ...albumRes.body.data, songs: songsData.body.data }, + apiClientProps.server, + ); + }, + getAlbumList: async (args) => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getAlbumList({ + query: { + _end: query.startIndex + (query.limit || 0), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: albumListSortMap.navidrome[query.sortBy], + _start: query.startIndex, + artist_id: query.artistIds?.[0], + compilation: query.compilation, + genre_id: query.genres?.[0], + name: query.searchTerm, + ...query._custom?.navidrome, + starred: query.favorite, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list'); + } + + return { + items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)), + startIndex: query?.startIndex || 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; + }, + getAlbumListCount: async ({ apiClientProps, query }) => + NavidromeController.getAlbumList({ + apiClientProps, + query: { ...query, limit: 1, startIndex: 0 }, + }).then((result) => result!.totalRecordCount!), + getDownloadUrl: SubsonicController.getDownloadUrl, + getGenreList: async (args) => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getGenreList({ + query: { + _end: query.startIndex + (query.limit || 0), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: genreListSortMap.navidrome[query.sortBy], + _start: query.startIndex, + name: query.searchTerm, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get genre list'); + } + + return { + items: res.body.data.map((genre) => ndNormalize.genre(genre)), + startIndex: query.startIndex || 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; + }, + getLyrics: SubsonicController.getLyrics, + getMusicFolderList: SubsonicController.getMusicFolderList, + getPlaylistDetail: async (args) => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getPlaylistDetail({ + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist detail'); + } + + return ndNormalize.playlist(res.body.data, apiClientProps.server); + }, + getPlaylistList: async (args) => { + const { query, apiClientProps } = args; + const customQuery = query._custom?.navidrome; + + // Smart playlists only became available in 0.48.0. Do not filter for previous versions + if ( + customQuery && + customQuery.smart !== undefined && + !hasFeature(apiClientProps.server, ServerFeature.PLAYLISTS_SMART) + ) { + customQuery.smart = undefined; + } + + const res = await ndApiClient(apiClientProps).getPlaylistList({ + query: { + _end: query.startIndex + (query.limit || 0), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined, + _start: query.startIndex, + q: query.searchTerm, + ...customQuery, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist list'); + } + + return { + items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)), + startIndex: query?.startIndex || 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; + }, + getPlaylistListCount: async ({ apiClientProps, query }) => + NavidromeController.getPlaylistList({ + apiClientProps, + query: { ...query, limit: 1, startIndex: 0 }, + }).then((result) => result!.totalRecordCount!), + getPlaylistSongList: async (args: PlaylistSongListArgs): Promise => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getPlaylistSongList({ + params: { + id: query.id, + }, + query: { + _end: query.limit, + _order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC', + _sort: query.sortBy + ? songListSortMap.navidrome[query.sortBy] + : ndType._enum.songList.ID, + _start: query.startIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist song list'); + } + + return { + items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')), + startIndex: query?.startIndex || 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; + }, + getRandomSongList: SubsonicController.getRandomSongList, + getServerInfo: async (args) => { + const { apiClientProps } = args; + + // Navidrome will always populate serverVersion + const ping = await ssApiClient(apiClientProps).ping(); + + if (ping.status !== 200) { + throw new Error('Failed to ping server'); + } + + const navidromeFeatures: Record = getFeatures( + VERSION_INFO, + ping.body.serverVersion!, + ); + + if (ping.body.openSubsonic) { + const res = await ssApiClient(apiClientProps).getServerInfo(); + + if (res.status !== 200) { + throw new Error('Failed to get server extensions'); + } + + // The type here isn't necessarily an array (even though it's supposed to be). This is + // an implementation detail of Navidrome 0.50. Do a type check to make sure it's actually + // an array, and not an empty object. + if (Array.isArray(res.body.openSubsonicExtensions)) { + for (const extension of res.body.openSubsonicExtensions) { + navidromeFeatures[extension.name] = extension.versions; + } } } - } - const features: ServerFeatures = { - lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS], - playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART], - publicPlaylist: true, - sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG], - }; + const features: ServerFeatures = { + lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS], + playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART], + publicPlaylist: true, + sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG], + }; - return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! }; -}; + return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! }; + }, + getSimilarSongs: async (args) => { + const { apiClientProps, query } = args; -const shareItem = async (args: ShareItemArgs): Promise => { - const { body, apiClientProps } = args; + // Prefer getSimilarSongs (which queries last.fm) where available + // otherwise find other tracks by the same album artist + const res = await ssApiClient({ + ...apiClientProps, + silent: true, + }).getSimilarSongs({ + query: { + count: query.count, + id: query.songId, + }, + }); - const res = await ndApiClient(apiClientProps).shareItem({ - body: { - description: body.description, - downloadable: body.downloadable, - expires: body.expires, - resourceIds: body.resourceIds, - resourceType: body.resourceType, - }, - }); + if (res.status === 200 && res.body.similarSongs?.song) { + const similar = res.body.similarSongs.song.reduce((acc, song) => { + if (song.id !== query.songId) { + acc.push(ssNormalize.song(song, apiClientProps.server, '')); + } - if (res.status !== 200) { - throw new Error('Failed to share item'); - } + return acc; + }, []); - return { - id: res.body.data.id, - }; -}; + if (similar.length > 0) { + return similar; + } + } -const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { - const { apiClientProps, query } = args; + const fallback = await ndApiClient(apiClientProps).getSongList({ + query: { + _end: 50, + _order: 'ASC', + _sort: NDSongListSort.RANDOM, + _start: 0, + album_artist_id: query.albumArtistIds, + }, + }); - // Prefer getSimilarSongs (which queries last.fm) where available - // otherwise find other tracks by the same album artist - const res = await ssApiClient({ - ...apiClientProps, - silent: true, - }).getSimilarSongs({ - query: { - count: query.count, - id: query.songId, - }, - }); + if (fallback.status !== 200) { + throw new Error('Failed to get similar songs'); + } - if (res.status === 200 && res.body.similarSongs?.song) { - const similar = res.body.similarSongs.song.reduce((acc, song) => { + return fallback.body.data.reduce((acc, song) => { if (song.id !== query.songId) { - acc.push(ssNormalize.song(song, apiClientProps.server, '')); + acc.push(ndNormalize.song(song, apiClientProps.server, '')); } return acc; }, []); + }, + getSongDetail: async (args) => { + const { query, apiClientProps } = args; - if (similar.length > 0) { - return similar; - } - } + const res = await ndApiClient(apiClientProps).getSongDetail({ + params: { + id: query.id, + }, + }); - const fallback = await ndApiClient(apiClientProps).getSongList({ - query: { - _end: 50, - _order: 'ASC', - _sort: NDSongListSort.RANDOM, - _start: 0, - album_artist_id: query.albumArtistIds, - }, - }); - - if (fallback.status !== 200) { - throw new Error('Failed to get similar songs'); - } - - return fallback.body.data.reduce((acc, song) => { - if (song.id !== query.songId) { - acc.push(ndNormalize.song(song, apiClientProps.server, '')); + if (res.status !== 200) { + throw new Error('Failed to get song detail'); } - return acc; - }, []); -}; - -const movePlaylistItem = async (args: MoveItemArgs): Promise => { - const { apiClientProps, query } = args; - - const res = await ndApiClient(apiClientProps).movePlaylistItem({ - body: { - insert_before: (query.endingIndex + 1).toString(), - }, - params: { - playlistId: query.playlistId, - trackNumber: query.startingIndex.toString(), - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to move item in playlist'); - } -}; - -export const ndController = { - addToPlaylist, - authenticate, - createPlaylist, - deletePlaylist, - getAlbumArtistDetail, - getAlbumArtistList, - getAlbumDetail, - getAlbumList, - getGenreList, - getPlaylistDetail, - getPlaylistList, - getPlaylistSongList, - getServerInfo, - getSimilarSongs, - getSongDetail, - getSongList, - getUserList, - movePlaylistItem, - removeFromPlaylist, - shareItem, - updatePlaylist, + return ndNormalize.song(res.body.data, apiClientProps.server, ''); + }, + getSongList: async (args) => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getSongList({ + query: { + _end: query.startIndex + (query.limit || -1), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: songListSortMap.navidrome[query.sortBy], + _start: query.startIndex, + album_artist_id: query.artistIds, + album_id: query.albumIds, + genre_id: query.genreIds, + starred: query.favorite, + title: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list'); + } + + return { + items: res.body.data.map((song) => + ndNormalize.song(song, apiClientProps.server, '', query.imageSize), + ), + startIndex: query?.startIndex || 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; + }, + getSongListCount: async ({ apiClientProps, query }) => + NavidromeController.getSongList({ + apiClientProps, + query: { ...query, limit: 1, startIndex: 0 }, + }).then((result) => result!.totalRecordCount!), + getTopSongs: SubsonicController.getTopSongs, + getTranscodingUrl: SubsonicController.getTranscodingUrl, + getUserList: async (args) => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getUserList({ + query: { + _end: query.startIndex + (query.limit || 0), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: userListSortMap.navidrome[query.sortBy], + _start: query.startIndex, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get user list'); + } + + return { + items: res.body.data.map((user) => ndNormalize.user(user)), + startIndex: query?.startIndex || 0, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; + }, + movePlaylistItem: async (args) => { + const { apiClientProps, query } = args; + + const res = await ndApiClient(apiClientProps).movePlaylistItem({ + body: { + insert_before: (query.endingIndex + 1).toString(), + }, + params: { + playlistId: query.playlistId, + trackNumber: query.startingIndex.toString(), + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to move item in playlist'); + } + }, + removeFromPlaylist: async (args) => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).removeFromPlaylist({ + body: null, + params: { + id: query.id, + }, + query: { + id: query.songId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to remove from playlist'); + } + + return null; + }, + scrobble: SubsonicController.scrobble, + search: SubsonicController.search, + setRating: SubsonicController.setRating, + shareItem: async (args) => { + const { body, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).shareItem({ + body: { + description: body.description, + downloadable: body.downloadable, + expires: body.expires, + resourceIds: body.resourceIds, + resourceType: body.resourceType, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to share item'); + } + + return { + id: res.body.data.id, + }; + }, + updatePlaylist: async (args) => { + const { query, body, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).updatePlaylist({ + body: { + comment: body.comment || '', + name: body.name, + public: body?.public || false, + rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined, + sync: body._custom?.navidrome?.sync || undefined, + }, + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to update playlist'); + } + + return null; + }, }; diff --git a/src/renderer/api/navidrome/navidrome-types.ts b/src/renderer/api/navidrome/navidrome-types.ts index 54d00c2a..a61a2d1c 100644 --- a/src/renderer/api/navidrome/navidrome-types.ts +++ b/src/renderer/api/navidrome/navidrome-types.ts @@ -224,7 +224,7 @@ const songListParameters = paginationParameters.extend({ album_artist_id: z.array(z.string()).optional(), album_id: z.array(z.string()).optional(), artist_id: z.array(z.string()).optional(), - genre_id: z.string().optional(), + genre_id: z.array(z.string()).optional(), path: z.string().optional(), starred: z.boolean().optional(), title: z.string().optional(), diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 3706bf0a..0dd872fe 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -50,6 +50,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; @@ -73,6 +86,27 @@ export const queryKeys: Record< }, }, albums: { + count: (serverId: string, query?: AlbumListQuery, artistId?: string) => { + const { pagination, filter } = splitPaginatedQuery(query); + + if (query && pagination && artistId) { + return [serverId, 'albums', 'count', artistId, filter, pagination] as const; + } + + if (query && pagination) { + return [serverId, 'albums', 'count', filter, pagination] as const; + } + + if (query && artistId) { + 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?: AlbumListQuery, artistId?: string) => { @@ -208,6 +242,18 @@ export const queryKeys: Record< root: (serverId: string) => [serverId] as const, }, songs: { + count: (serverId: string, query?: SongListQuery) => { + 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 bb0dc3c1..4fe383a0 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -27,6 +27,46 @@ export const contract = c.router({ 200: ssType._response.createFavorite, }, }, + createPlaylist: { + method: 'GET', + path: 'createPlaylist.view', + query: ssType._parameters.createPlaylist, + responses: { + 200: ssType._response.createPlaylist, + }, + }, + deletePlaylist: { + method: 'GET', + path: 'deletePlaylist.view', + query: ssType._parameters.deletePlaylist, + responses: { + 200: ssType._response.baseResponse, + }, + }, + getAlbum: { + method: 'GET', + path: 'getAlbum.view', + query: ssType._parameters.getAlbum, + responses: { + 200: ssType._response.getAlbum, + }, + }, + getAlbumList2: { + method: 'GET', + path: 'getAlbumList2.view', + query: ssType._parameters.getAlbumList2, + responses: { + 200: ssType._response.getAlbumList2, + }, + }, + getArtist: { + method: 'GET', + path: 'getArtist.view', + query: ssType._parameters.getArtist, + responses: { + 200: ssType._response.getArtist, + }, + }, getArtistInfo: { method: 'GET', path: 'getArtistInfo.view', @@ -35,6 +75,22 @@ export const contract = c.router({ 200: ssType._response.artistInfo, }, }, + getArtists: { + method: 'GET', + path: 'getArtists.view', + query: ssType._parameters.getArtists, + responses: { + 200: ssType._response.getArtists, + }, + }, + getGenres: { + method: 'GET', + path: 'getGenres.view', + query: ssType._parameters.getGenres, + responses: { + 200: ssType._response.getGenres, + }, + }, getMusicFolderList: { method: 'GET', path: 'getMusicFolders.view', @@ -42,6 +98,22 @@ export const contract = c.router({ 200: ssType._response.musicFolderList, }, }, + getPlaylist: { + method: 'GET', + path: 'getPlaylist.view', + query: ssType._parameters.getPlaylist, + responses: { + 200: ssType._response.getPlaylist, + }, + }, + getPlaylists: { + method: 'GET', + path: 'getPlaylists.view', + query: ssType._parameters.getPlaylists, + responses: { + 200: ssType._response.getPlaylists, + }, + }, getRandomSongList: { method: 'GET', path: 'getRandomSongs.view', @@ -65,6 +137,30 @@ export const contract = c.router({ 200: ssType._response.similarSongs, }, }, + getSong: { + method: 'GET', + path: 'getSong.view', + query: ssType._parameters.getSong, + responses: { + 200: ssType._response.getSong, + }, + }, + getSongsByGenre: { + method: 'GET', + path: 'getSongsByGenre.view', + query: ssType._parameters.getSongsByGenre, + responses: { + 200: ssType._response.getSongsByGenre, + }, + }, + getStarred: { + method: 'GET', + path: 'getStarred.view', + query: ssType._parameters.getStarred, + responses: { + 200: ssType._response.getStarred, + }, + }, getStructuredLyrics: { method: 'GET', path: 'getLyricsBySongId.view', @@ -120,6 +216,14 @@ export const contract = c.router({ 200: ssType._response.setRating, }, }, + updatePlaylist: { + method: 'GET', + path: 'updatePlaylist.view', + query: ssType._parameters.updatePlaylist, + responses: { + 200: ssType._response.baseResponse, + }, + }, }); const axiosClient = axios.create({}); diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index b575a8dc..7c294698 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1,103 +1,1287 @@ +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 { SubsonicExtensions, ssType } from '/@/renderer/api/subsonic/subsonic-types'; +import { AlbumListSortType, SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types'; import { - ArtistInfoArgs, - AuthenticationResponse, - FavoriteArgs, - FavoriteResponse, LibraryItem, - MusicFolderListArgs, - MusicFolderListResponse, - SetRatingArgs, - RatingResponse, - ScrobbleArgs, - ScrobbleResponse, - SongListResponse, - TopSongListArgs, - SearchArgs, - SearchResponse, - RandomSongListResponse, - RandomSongListArgs, - ServerInfo, - ServerInfoArgs, - StructuredLyricsArgs, - StructuredLyric, - SimilarSongsArgs, Song, - DownloadArgs, - TranscodingArgs, + ControllerEndpoint, + sortSongList, + sortAlbumArtistList, + PlaylistListSort, + GenreListSort, + AlbumListSort, + sortAlbumList, } from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; import { ServerFeatures } from '/@/renderer/api/features-types'; -const authenticate = async ( - url: string, - body: { - legacy?: boolean; - password: string; - username: string; - }, -): Promise => { - let credential: string; - let credentialParams: { - p?: string; - s?: string; - t?: string; - u: string; - }; - - const cleanServerUrl = url.replace(/\/$/, ''); - - if (body.legacy) { - credential = `u=${body.username}&p=${body.password}`; - credentialParams = { - p: body.password, - u: body.username, - }; - } else { - const salt = randomString(12); - const hash = md5(body.password + salt); - credential = `u=${body.username}&s=${salt}&t=${hash}`; - credentialParams = { - s: salt, - t: hash, - u: body.username, - }; - } - - await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({ - query: { - c: 'Feishin', - f: 'json', - v: '1.13.0', - ...credentialParams, - }, - }); - - return { - credential, - userId: null, - username: body.username, - }; +const ALBUM_LIST_SORT_MAPPING: Record = { + [AlbumListSort.RANDOM]: AlbumListSortType.RANDOM, + [AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST, + [AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT, + [AlbumListSort.RECENTLY_ADDED]: AlbumListSortType.NEWEST, + [AlbumListSort.FAVORITED]: AlbumListSortType.STARRED, + [AlbumListSort.YEAR]: AlbumListSortType.RECENT, + [AlbumListSort.NAME]: 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, }; -const getMusicFolderList = async (args: MusicFolderListArgs): Promise => { - const { apiClientProps } = args; +export const SubsonicController: ControllerEndpoint = { + addToPlaylist: async ({ body, query, apiClientProps }) => { + const res = await ssApiClient(apiClientProps).updatePlaylist({ + query: { + playlistId: query.id, + songIdToAdd: body.songId, + }, + }); - const res = await ssApiClient(apiClientProps).getMusicFolderList({}); + if (res.status !== 200) { + throw new Error('Failed to add to playlist'); + } - if (res.status !== 200) { - throw new Error('Failed to get music folder list'); - } + return null; + }, + authenticate: async (url, body) => { + let credential: string; + let credentialParams: { + p?: string; + s?: string; + t?: string; + u: string; + }; - return { - items: res.body.musicFolders.musicFolder, - startIndex: 0, - totalRecordCount: res.body.musicFolders.musicFolder.length, - }; + const cleanServerUrl = `${url.replace(/\/$/, '')}/rest`; + + if (body.legacy) { + credential = `u=${body.username}&p=${body.password}`; + credentialParams = { + p: body.password, + u: body.username, + }; + } else { + const salt = randomString(12); + const hash = md5(body.password + salt); + credential = `u=${body.username}&s=${salt}&t=${hash}`; + credentialParams = { + s: salt, + t: hash, + u: body.username, + }; + } + + await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({ + query: { + c: 'Feishin', + f: 'json', + v: '1.13.0', + ...credentialParams, + }, + }); + + return { + credential, + userId: null, + username: body.username, + }; + }, + createFavorite: async (args) => { + 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, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to create favorite'); + } + + return null; + }, + createPlaylist: async ({ body, apiClientProps }) => { + const res = await ssApiClient(apiClientProps).createPlaylist({ + query: { + name: body.name, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to create playlist'); + } + + return { + id: res.body.playlist.id, + name: res.body.playlist.name, + }; + }, + deleteFavorite: async (args) => { + const { query, apiClientProps } = args; + + 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) { + throw new Error('Failed to delete favorite'); + } + + return null; + }, + deletePlaylist: async (args) => { + const { query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).deletePlaylist({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to delete playlist'); + } + + return null; + }, + getAlbumArtistDetail: async (args) => { + const { query, apiClientProps } = args; + + const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({ + query: { + id: query.id, + }, + }); + + const res = await ssApiClient(apiClientProps).getArtist({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album artist detail'); + } + + const artist = res.body.artist; + + let artistInfo; + if (artistInfoRes.status === 200) { + artistInfo = artistInfoRes.body.artistInfo; + } + + return { + ...ssNormalize.albumArtist(artist, apiClientProps.server, 300), + albums: artist.album.map((album) => ssNormalize.album(album, apiClientProps.server)), + similarArtists: + artistInfo?.similarArtist?.map((artist) => + ssNormalize.albumArtist(artist, apiClientProps.server, 300), + ) || null, + }; + }, + getAlbumArtistList: async (args) => { + const { query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).getArtists({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album artist list'); + } + + const artists = (res.body.artists?.index || []).flatMap((index) => index.artist); + + let results = artists.map((artist) => + ssNormalize.albumArtist(artist, apiClientProps.server, 300), + ); + + if (query.searchTerm) { + const searchResults = filter(results, (artist) => { + return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + } + + if (query.sortBy) { + results = sortAlbumArtistList(results, query.sortBy, query.sortOrder); + } + + return { + items: results, + startIndex: query.startIndex, + totalRecordCount: results?.length || 0, + }; + }, + getAlbumArtistListCount: (args) => + SubsonicController.getAlbumArtistList(args).then((res) => res!.totalRecordCount!), + getAlbumDetail: async (args) => { + const { query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).getAlbum({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album detail'); + } + + return ssNormalize.album(res.body.album, apiClientProps.server); + }, + getAlbumList: async (args) => { + const { query, apiClientProps } = args; + + if (query.searchTerm) { + const res = await ssApiClient(apiClientProps).search3({ + query: { + albumCount: query.limit, + albumOffset: query.startIndex, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 0, + songOffset: 0, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list'); + } + + const results = + res.body.searchResult3.album?.map((album) => + ssNormalize.album(album, apiClientProps.server), + ) || []; + + return { + items: results, + startIndex: query.startIndex, + totalRecordCount: null, + }; + } + + let type = ALBUM_LIST_SORT_MAPPING[query.sortBy] ?? AlbumListSortType.ALPHABETICAL_BY_NAME; + + if (query.artistIds) { + const promises = []; + + for (const artistId of query.artistIds) { + promises.push( + ssApiClient(apiClientProps).getArtist({ + query: { + id: artistId, + }, + }), + ); + } + + const artistResult = await Promise.all(promises); + + const albums = artistResult.flatMap((artist) => { + if (artist.status !== 200) { + return []; + } + + return artist.body.artist.album; + }); + + return { + items: albums.map((album) => ssNormalize.album(album, apiClientProps.server)), + startIndex: 0, + totalRecordCount: albums.length, + }; + } + + if (query.favorite) { + const res = await ssApiClient(apiClientProps).getStarred({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list'); + } + + const results = + res.body.starred.album?.map((album) => + ssNormalize.album(album, apiClientProps.server), + ) || []; + + return { + items: sortAlbumList(results, query.sortBy, query.sortOrder), + startIndex: 0, + totalRecordCount: res.body.starred.album?.length || 0, + }; + } + + if (query.genres?.length) { + type = AlbumListSortType.BY_GENRE; + } + + if (query.minYear || query.maxYear) { + type = 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 ssApiClient(apiClientProps).getAlbumList2({ + query: { + fromYear, + genre: query.genres?.length ? query.genres[0] : undefined, + musicFolderId: query.musicFolderId, + offset: query.startIndex, + size: query.limit, + toYear, + type, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list'); + } + + return { + items: + res.body.albumList2.album?.map((album) => + ssNormalize.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 ssApiClient(apiClientProps).search3({ + query: { + albumCount: 500, + albumOffset: startIndex, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 0, + songOffset: 0, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list count'); + } + + const albumCount = res.body.searchResult3.album?.length; + + totalRecordCount += albumCount; + startIndex += albumCount; + + // The max limit size for Subsonic is 500 + fetchNextPage = albumCount === 500; + } + + return totalRecordCount; + } + + if (query.favorite) { + const res = await ssApiClient(apiClientProps).getStarred({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list'); + } + + return res.body.starred.album?.length || 0; + } + + let type = ALBUM_LIST_SORT_MAPPING[query.sortBy] ?? AlbumListSortType.ALPHABETICAL_BY_NAME; + + let fetchNextPage = true; + let startIndex = 0; + let totalRecordCount = 0; + + if (query.genres?.length) { + type = AlbumListSortType.BY_GENRE; + } + + if (query.minYear || query.maxYear) { + type = 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 ssApiClient(apiClientProps).getAlbumList2({ + query: { + fromYear, + genre: query.genres?.length ? query.genres[0] : undefined, + 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) { + throw new Error('Failed to get album list count'); + } + + const albumCount = res.body.albumList2.album.length; + + totalRecordCount += albumCount; + startIndex += albumCount; + + // The max limit size for Subsonic is 500 + fetchNextPage = albumCount === 500; + } + + return totalRecordCount; + }, + getDownloadUrl: (args) => { + const { apiClientProps, query } = args; + + return ( + `${apiClientProps.server?.url}/rest/download.view` + + `?id=${query.id}` + + `&${apiClientProps.server?.credential}` + + '&v=1.13.0' + + '&c=feishin' + ); + }, + getGenreList: async ({ query, apiClientProps }) => { + const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; + + const res = await ssApiClient(apiClientProps).getGenres({}); + + if (res.status !== 200) { + throw new Error('Failed to get genre list'); + } + + let results = res.body.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; + default: + break; + } + + const genres = results.map(ssNormalize.genre); + + return { + items: genres, + startIndex: 0, + totalRecordCount: genres.length, + }; + }, + getMusicFolderList: async (args) => { + const { apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).getMusicFolderList({}); + + if (res.status !== 200) { + throw new Error('Failed to get music folder list'); + } + + return { + items: res.body.musicFolders.musicFolder, + startIndex: 0, + totalRecordCount: res.body.musicFolders.musicFolder.length, + }; + }, + getPlaylistDetail: async (args) => { + const { query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).getPlaylist({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist detail'); + } + + return ssNormalize.playlist(res.body.playlist, apiClientProps.server); + }, + getPlaylistList: async ({ query, apiClientProps }) => { + const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; + + const res = await ssApiClient(apiClientProps).getPlaylists({}); + + if (res.status !== 200) { + throw new Error('Failed to get playlist list'); + } + + let results = res.body.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) => ssNormalize.playlist(playlist, apiClientProps.server)), + startIndex: 0, + totalRecordCount: results.length, + }; + }, + getPlaylistListCount: async ({ query, apiClientProps }) => { + const res = await ssApiClient(apiClientProps).getPlaylists({}); + + if (res.status !== 200) { + throw new Error('Failed to get playlist list'); + } + + let results = res.body.playlists.playlist; + + if (query.searchTerm) { + const searchResults = filter(results, (playlist) => { + return playlist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + } + + return results.length; + }, + getPlaylistSongList: async ({ query, apiClientProps }) => { + const res = await ssApiClient(apiClientProps).getPlaylist({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist song list'); + } + + let results = + res.body.playlist.entry?.map((song) => + ssNormalize.song(song, apiClientProps.server, ''), + ) || []; + + if (query.sortBy && query.sortOrder) { + 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 ssApiClient(apiClientProps).getRandomSongList({ + query: { + fromYear: query.minYear, + genre: query.genre, + musicFolderId: query.musicFolderId, + size: query.limit, + toYear: query.maxYear, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get random songs'); + } + + return { + items: res.body.randomSongs?.song?.map((song) => + ssNormalize.song(song, apiClientProps.server, ''), + ), + startIndex: 0, + totalRecordCount: res.body.randomSongs?.song?.length || 0, + }; + }, + getServerInfo: async (args) => { + const { apiClientProps } = args; + + const ping = await ssApiClient(apiClientProps).ping(); + + if (ping.status !== 200) { + throw new Error('Failed to ping server'); + } + + const features: ServerFeatures = {}; + + if (!ping.body.openSubsonic || !ping.body.serverVersion) { + return { features, version: ping.body.version }; + } + + const res = await ssApiClient(apiClientProps).getServerInfo(); + + if (res.status !== 200) { + throw new Error('Failed to get server extensions'); + } + + const subsonicFeatures: Record = {}; + if (Array.isArray(res.body.openSubsonicExtensions)) { + for (const extension of res.body.openSubsonicExtensions) { + subsonicFeatures[extension.name] = extension.versions; + } + } + + if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) { + features.lyricsMultipleStructured = true; + } + + return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; + }, + getSimilarSongs: async (args) => { + const { apiClientProps, query } = args; + + const res = await ssApiClient(apiClientProps).getSimilarSongs({ + query: { + count: query.count, + id: query.songId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get similar songs'); + } + + if (!res.body.similarSongs?.song) { + return []; + } + + return res.body.similarSongs.song.reduce((acc, song) => { + if (song.id !== query.songId) { + acc.push(ssNormalize.song(song, apiClientProps.server, '')); + } + + return acc; + }, []); + }, + getSongDetail: async (args) => { + const { query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).getSong({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song detail'); + } + + return ssNormalize.song(res.body.song, apiClientProps.server, ''); + }, + getSongList: async ({ query, apiClientProps }) => { + const fromAlbumPromises = []; + const artistDetailPromises = []; + let results: any[] = []; + + if (query.searchTerm) { + const res = await ssApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: query.limit, + songOffset: query.startIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list'); + } + + return { + items: + res.body.searchResult3?.song?.map((song) => + ssNormalize.song(song, apiClientProps.server, ''), + ) || [], + startIndex: query.startIndex, + totalRecordCount: null, + }; + } + + if (query.genreIds) { + const res = await ssApiClient(apiClientProps).getSongsByGenre({ + query: { + count: query.limit, + genre: query.genreIds[0], + musicFolderId: query.musicFolderId, + offset: query.startIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list'); + } + + return { + items: + res.body.songsByGenre.song?.map((song) => + ssNormalize.song(song, apiClientProps.server, ''), + ) || [], + startIndex: 0, + totalRecordCount: null, + }; + } + + if (query.favorite) { + const res = await ssApiClient(apiClientProps).getStarred({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list'); + } + + const results = + res.body.starred.song?.map((song) => + ssNormalize.song(song, apiClientProps.server, ''), + ) || []; + + return { + items: sortSongList(results, query.sortBy, query.sortOrder), + startIndex: 0, + totalRecordCount: res.body.starred.song?.length || 0, + }; + } + + if (query.albumIds || query.artistIds) { + if (query.albumIds) { + for (const albumId of query.albumIds) { + fromAlbumPromises.push( + ssApiClient(apiClientProps).getAlbum({ + query: { + id: albumId, + }, + }), + ); + } + } + + if (query.artistIds) { + for (const artistId of query.artistIds) { + artistDetailPromises.push( + ssApiClient(apiClientProps).getArtist({ + query: { + id: artistId, + }, + }), + ); + } + + const artistResult = await Promise.all(artistDetailPromises); + + const albums = artistResult.flatMap((artist) => { + if (artist.status !== 200) { + return []; + } + + return artist.body.artist.album; + }); + + const albumIds = albums.map((album) => album.id); + + for (const albumId of albumIds) { + fromAlbumPromises.push( + ssApiClient(apiClientProps).getAlbum({ + query: { + id: albumId, + }, + }), + ); + } + } + + if (fromAlbumPromises) { + const albumsResult = await Promise.all(fromAlbumPromises); + + results = albumsResult.flatMap((album) => { + if (album.status !== 200) { + return []; + } + + return album.body.album.song; + }); + } + + return { + items: results.map((song) => ssNormalize.song(song, apiClientProps.server, '')), + startIndex: 0, + totalRecordCount: results.length, + }; + } + + const res = await ssApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: query.limit, + songOffset: query.startIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list'); + } + + return { + items: + res.body.searchResult3?.song?.map((song) => + ssNormalize.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 ssApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 500, + songOffset: startIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list count'); + } + + const songCount = res.body.searchResult3.song?.length || 0; + + totalRecordCount += songCount; + startIndex += songCount; + + // The max limit size for Subsonic is 500 + fetchNextPage = songCount === 500; + } + + return totalRecordCount; + } + + if (query.genreIds) { + let totalRecordCount = 0; + while (fetchNextSection) { + const res = await ssApiClient(apiClientProps).getSongsByGenre({ + query: { + count: 1, + genre: query.genreIds[0], + musicFolderId: query.musicFolderId, + offset: sectionIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list count'); + } + + const numberOfResults = res.body.songsByGenre.song?.length || 0; + + if (numberOfResults !== 1) { + fetchNextSection = false; + startIndex = sectionIndex === 0 ? 0 : sectionIndex - 5000; + break; + } else { + sectionIndex += 5000; + } + } + + while (fetchNextPage) { + const res = await ssApiClient(apiClientProps).getSongsByGenre({ + query: { + count: 500, + genre: query.genreIds[0], + musicFolderId: query.musicFolderId, + offset: startIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list count'); + } + + const numberOfResults = res.body.songsByGenre.song?.length || 0; + + totalRecordCount = startIndex + numberOfResults; + startIndex += numberOfResults; + + fetchNextPage = numberOfResults === 500; + } + + return totalRecordCount; + } + + if (query.favorite) { + const res = await ssApiClient(apiClientProps).getStarred({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list'); + } + + return res.body.starred.song?.length || 0; + } + + let totalRecordCount = 0; + + while (fetchNextSection) { + const res = await ssApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 1, + songOffset: sectionIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list count'); + } + + const numberOfResults = res.body.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 ssApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 500, + songOffset: startIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list count'); + } + + const numberOfResults = res.body.searchResult3.song?.length || 0; + + totalRecordCount = startIndex + numberOfResults; + startIndex += numberOfResults; + + // The max limit size for Subsonic is 500 + fetchNextPage = numberOfResults === 500; + } + + return totalRecordCount; + }, + getStructuredLyrics: async (args) => { + const { query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).getStructuredLyrics({ + query: { + id: query.songId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get structured lyrics'); + } + + const lyrics = res.body.lyricsList?.structuredLyrics; + + if (!lyrics) { + return []; + } + + return lyrics.map((lyric) => { + const baseLyric = { + artist: lyric.displayArtist || '', + lang: lyric.lang, + name: lyric.displayTitle || '', + remote: false, + source: apiClientProps.server?.name || 'music server', + }; + + if (lyric.synced) { + return { + ...baseLyric, + lyrics: lyric.line.map((line) => [line.start!, line.value]), + synced: true, + }; + } + return { + ...baseLyric, + lyrics: lyric.line.map((line) => [line.value]).join('\n'), + synced: false, + }; + }); + }, + getTopSongs: async (args) => { + const { query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).getTopSongsList({ + query: { + artist: query.artist, + count: query.limit, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get top songs'); + } + + return { + items: + res.body.topSongs?.song?.map((song) => + ssNormalize.song(song, apiClientProps.server, ''), + ) || [], + startIndex: 0, + totalRecordCount: res.body.topSongs?.song?.length || 0, + }; + }, + getTranscodingUrl: (args) => { + const { base, format, bitrate } = args.query; + let url = base; + if (format) { + url += `&format=${format}`; + } + if (bitrate !== undefined) { + url += `&maxBitRate=${bitrate}`; + } + + return url; + }, + removeFromPlaylist: async ({ query, apiClientProps }) => { + const res = await ssApiClient(apiClientProps).updatePlaylist({ + query: { + playlistId: query.id, + songIndexToRemove: query.songId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to add to playlist'); + } + + return null; + }, + scrobble: async (args) => { + const { query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).scrobble({ + query: { + id: query.id, + submission: query.submission, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to scrobble'); + } + + return null; + }, + search: async (args) => { + const { query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).search3({ + query: { + albumCount: query.albumLimit, + albumOffset: query.albumStartIndex, + artistCount: query.albumArtistLimit, + artistOffset: query.albumArtistStartIndex, + query: query.query, + songCount: query.songLimit, + songOffset: query.songStartIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to search'); + } + + 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, ''), + ), + }; + }, + setRating: async (args) => { + const { query, apiClientProps } = args; + + const itemIds = query.item.map((item) => item.id); + + for (const id of itemIds) { + await ssApiClient(apiClientProps).setRating({ + query: { + id, + rating: query.rating, + }, + }); + } + + return null; + }, + updatePlaylist: async (args) => { + const { body, query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).updatePlaylist({ + query: { + comment: body.comment, + name: body.name, + playlistId: query.id, + public: body.public, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to add to playlist'); + } + + return null; + }, }; // export const getAlbumArtistDetail = async ( @@ -205,324 +1389,3 @@ const getMusicFolderList = async (args: MusicFolderListArgs): 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, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to create favorite'); - } - - return null; -}; - -const removeFavorite = async (args: FavoriteArgs): Promise => { - const { query, apiClientProps } = args; - - 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) { - throw new Error('Failed to delete favorite'); - } - - return null; -}; - -const setRating = async (args: SetRatingArgs): Promise => { - const { query, apiClientProps } = args; - - const itemIds = query.item.map((item) => item.id); - - for (const id of itemIds) { - await ssApiClient(apiClientProps).setRating({ - query: { - id, - rating: query.rating, - }, - }); - } - - return null; -}; - -const getTopSongList = async (args: TopSongListArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ssApiClient(apiClientProps).getTopSongsList({ - query: { - artist: query.artist, - count: query.limit, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get top songs'); - } - - return { - items: - res.body.topSongs?.song?.map((song) => - ssNormalize.song(song, apiClientProps.server, ''), - ) || [], - startIndex: 0, - totalRecordCount: res.body.topSongs?.song?.length || 0, - }; -}; - -const getArtistInfo = async ( - args: ArtistInfoArgs, -): Promise> => { - const { query, apiClientProps } = args; - - const res = await ssApiClient(apiClientProps).getArtistInfo({ - query: { - count: query.limit, - id: query.artistId, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get artist info'); - } - - return res.body; -}; - -const scrobble = async (args: ScrobbleArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ssApiClient(apiClientProps).scrobble({ - query: { - id: query.id, - submission: query.submission, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to scrobble'); - } - - return null; -}; - -const search3 = async (args: SearchArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ssApiClient(apiClientProps).search3({ - query: { - albumCount: query.albumLimit, - albumOffset: query.albumStartIndex, - artistCount: query.albumArtistLimit, - artistOffset: query.albumArtistStartIndex, - query: query.query, - songCount: query.songLimit, - songOffset: query.songStartIndex, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to search'); - } - - 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, ''), - ), - }; -}; - -const getRandomSongList = async (args: RandomSongListArgs): Promise => { - 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, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get random songs'); - } - - return { - items: res.body.randomSongs?.song?.map((song) => - ssNormalize.song(song, apiClientProps.server, ''), - ), - startIndex: 0, - totalRecordCount: res.body.randomSongs?.song?.length || 0, - }; -}; - -const getServerInfo = async (args: ServerInfoArgs): Promise => { - const { apiClientProps } = args; - - const ping = await ssApiClient(apiClientProps).ping(); - - if (ping.status !== 200) { - throw new Error('Failed to ping server'); - } - - const features: ServerFeatures = {}; - - if (!ping.body.openSubsonic || !ping.body.serverVersion) { - return { features, version: ping.body.version }; - } - - const res = await ssApiClient(apiClientProps).getServerInfo(); - - if (res.status !== 200) { - throw new Error('Failed to get server extensions'); - } - - const subsonicFeatures: Record = {}; - if (Array.isArray(res.body.openSubsonicExtensions)) { - for (const extension of res.body.openSubsonicExtensions) { - subsonicFeatures[extension.name] = extension.versions; - } - } - - if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) { - features.lyricsMultipleStructured = true; - } - - return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; -}; - -export const getStructuredLyrics = async ( - args: StructuredLyricsArgs, -): Promise => { - const { query, apiClientProps } = args; - - const res = await ssApiClient(apiClientProps).getStructuredLyrics({ - query: { - id: query.songId, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get structured lyrics'); - } - - const lyrics = res.body.lyricsList?.structuredLyrics; - - if (!lyrics) { - return []; - } - - return lyrics.map((lyric) => { - const baseLyric = { - artist: lyric.displayArtist || '', - lang: lyric.lang, - name: lyric.displayTitle || '', - remote: false, - source: apiClientProps.server?.name || 'music server', - }; - - if (lyric.synced) { - return { - ...baseLyric, - lyrics: lyric.line.map((line) => [line.start!, line.value]), - synced: true, - }; - } - return { - ...baseLyric, - lyrics: lyric.line.map((line) => [line.value]).join('\n'), - synced: false, - }; - }); -}; - -const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { - const { apiClientProps, query } = args; - - const res = await ssApiClient(apiClientProps).getSimilarSongs({ - query: { - count: query.count, - id: query.songId, - }, - }); - - if (res.status !== 200) { - throw new Error('Failed to get similar songs'); - } - - if (!res.body.similarSongs?.song) { - return []; - } - - return res.body.similarSongs.song.reduce((acc, song) => { - if (song.id !== query.songId) { - acc.push(ssNormalize.song(song, apiClientProps.server, '')); - } - - return acc; - }, []); -}; - -const getDownloadUrl = (args: DownloadArgs) => { - const { apiClientProps, query } = args; - - return ( - `${apiClientProps.server?.url}/rest/download.view` + - `?id=${query.id}` + - `&${apiClientProps.server?.credential}` + - '&v=1.13.0' + - '&c=feishin' - ); -}; - -const getTranscodingUrl = (args: TranscodingArgs) => { - const { base, format, bitrate } = args.query; - let url = base; - if (format) { - url += `&format=${format}`; - } - if (bitrate !== undefined) { - url += `&maxBitRate=${bitrate}`; - } - - return url; -}; - -export const ssController = { - authenticate, - createFavorite, - getArtistInfo, - getDownloadUrl, - getMusicFolderList, - getRandomSongList, - getServerInfo, - getSimilarSongs, - getStructuredLyrics, - getTopSongList, - getTranscodingUrl, - removeFavorite, - scrobble, - search3, - setRating, -}; diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index 624765b5..ca667397 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -8,6 +8,8 @@ import { Album, ServerListItem, ServerType, + Playlist, + Genre, } from '/@/renderer/api/types'; const getCoverArtUrl = (args: { @@ -36,13 +38,14 @@ const normalizeSong = ( 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}`; @@ -66,7 +69,7 @@ const normalizeSong = ( }, ], bitRate: item.bitRate || 0, - bpm: null, + bpm: item.bpm || null, channels: null, comment: null, compilation: null, @@ -123,15 +126,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 { @@ -157,15 +163,16 @@ const normalizeAlbumArtist = ( }; const normalizeAlbum = ( - item: z.infer, + item: z.infer | z.infer, server: ServerListItem | null, + imageSize?: number, ): Album => { const imageUrl = getCoverArtUrl({ baseUrl: server?.url, coverArtId: item.coverArt, credential: server?.credential, - size: 300, + size: imageSize || 300, }) || null; return { @@ -177,7 +184,7 @@ const normalizeAlbum = ( backdropImageUrl: null, comment: null, createdAt: item.created, - duration: item.duration, + duration: item.duration * 1000, genres: item.genre ? [ { @@ -204,7 +211,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, @@ -212,8 +222,51 @@ const normalizeAlbum = ( }; }; +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, + }; +}; + +const normalizeGenre = (item: z.infer): Genre => { + return { + albumCount: item.albumCount, + id: item.value, + imageUrl: null, + itemType: LibraryItem.GENRE, + name: item.value, + songCount: item.songCount, + }; +}; + export const ssNormalize = { album: normalizeAlbum, albumArtist: normalizeAlbumArtist, + genre: normalizeGenre, + playlist: normalizePlaylist, song: normalizeSong, }; diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index eba145c5..7f5ae6c6 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -60,6 +60,10 @@ const songGain = z.object({ trackPeak: z.number().optional(), }); +const genreItem = z.object({ + name: z.string(), +}); + const song = z.object({ album: z.string().optional(), albumId: z.string().optional(), @@ -67,15 +71,18 @@ const song = z.object({ artistId: z.string().optional(), averageRating: z.number().optional(), bitRate: z.number().optional(), + bpm: z.number().optional(), contentType: z.string(), coverArt: z.string().optional(), created: z.string(), discNumber: z.number(), duration: z.number().optional(), genre: z.string().optional(), + genres: z.array(genreItem).optional(), id: z.string(), isDir: z.boolean(), isVideo: z.boolean(), + musicBrainzId: z.string().optional(), parent: z.string(), path: z.string(), playCount: z.number().optional(), @@ -99,6 +106,7 @@ const album = z.object({ duration: z.number(), genre: z.string().optional(), id: z.string(), + isCompilation: z.boolean().optional(), isDir: z.boolean(), isVideo: z.boolean(), name: z.string(), @@ -111,6 +119,10 @@ const album = z.object({ year: z.number().optional(), }); +const albumListEntry = album.omit({ + song: true, +}); + const albumListParameters = z.object({ fromYear: z.number().optional(), genre: z.string().optional(), @@ -124,11 +136,13 @@ const albumListParameters = z.object({ const albumList = z.array(album.omit({ song: true })); const albumArtist = z.object({ + album: z.array(album), albumCount: z.string(), artistImageUrl: z.string().optional(), coverArt: z.string().optional(), id: z.string(), name: z.string(), + starred: z.string().optional(), }); const albumArtistList = z.object({ @@ -136,6 +150,14 @@ const albumArtistList = z.object({ name: z.string(), }); +const artistListEntry = albumArtist.pick({ + albumCount: true, + coverArt: true, + id: true, + name: true, + starred: true, +}); + const artistInfoParameters = z.object({ count: z.number().optional(), id: z.string(), @@ -274,12 +296,215 @@ export enum SubsonicExtensions { TRANSCODE_OFFSET = 'transcodeOffset', } +const updatePlaylistParameters = z.object({ + comment: z.string().optional(), + name: z.string().optional(), + playlistId: z.string(), + public: z.boolean().optional(), + songIdToAdd: z.array(z.string()).optional(), + songIndexToRemove: z.array(z.string()).optional(), +}); + +const getStarredParameters = z.object({ + musicFolderId: z.string().optional(), +}); + +const getStarred = z.object({ + starred: z.object({ + album: z.array(albumListEntry), + artist: z.array(artistListEntry), + song: z.array(song), + }), +}); + +const getSongsByGenreParameters = z.object({ + count: z.number().optional(), + genre: z.string(), + musicFolderId: z.string().optional(), + offset: z.number().optional(), +}); + +const getSongsByGenre = z.object({ + songsByGenre: z.object({ + song: z.array(song), + }), +}); + +const getAlbumParameters = z.object({ + id: z.string(), + musicFolderId: z.string().optional(), +}); + +const getAlbum = z.object({ + album, +}); + +const getArtistParameters = z.object({ + id: z.string(), +}); + +const getArtist = z.object({ + artist: albumArtist, +}); + +const getSongParameters = z.object({ + id: z.string(), +}); + +const getSong = z.object({ + song, +}); + +const getArtistsParameters = z.object({ + musicFolderId: z.string().optional(), +}); + +const getArtists = z.object({ + artists: z.object({ + ignoredArticles: z.string(), + index: z.array( + z.object({ + artist: z.array(artistListEntry), + name: z.string(), + }), + ), + }), +}); + +const deletePlaylistParameters = z.object({ + id: z.string(), +}); + +const createPlaylistParameters = z.object({ + name: z.string(), + playlistId: z.string().optional(), + songId: z.array(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 createPlaylist = z.object({ + playlist, +}); + +const getPlaylistsParameters = z.object({ + username: z.string().optional(), +}); + +const playlistListEntry = playlist.omit({ + entry: true, +}); + +const getPlaylists = z.object({ + playlists: z.object({ + playlist: z.array(playlistListEntry), + }), +}); + +const getPlaylistParameters = z.object({ + id: z.string(), +}); + +const getPlaylist = z.object({ + playlist, +}); + +const genre = z.object({ + albumCount: z.number(), + songCount: z.number(), + value: z.string(), +}); + +const getGenresParameters = z.object({}); + +const getGenres = z.object({ + genres: z.object({ + genre: z.array(genre), + }), +}); + +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 getAlbumList2Parameters = 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"' }, + ); + +const getAlbumList2 = z.object({ + albumList2: z.object({ + album: z.array(albumListEntry), + }), +}); + export const ssType = { _parameters: { albumList: albumListParameters, artistInfo: artistInfoParameters, authenticate: authenticateParameters, createFavorite: createFavoriteParameters, + createPlaylist: createPlaylistParameters, + deletePlaylist: deletePlaylistParameters, + getAlbum: getAlbumParameters, + getAlbumList2: getAlbumList2Parameters, + getArtist: getArtistParameters, + getArtists: getArtistsParameters, + getGenre: getGenresParameters, + getGenres: getGenresParameters, + getPlaylist: getPlaylistParameters, + getPlaylists: getPlaylistsParameters, + getSong: getSongParameters, + getSongsByGenre: getSongsByGenreParameters, + getStarred: getStarredParameters, randomSongList: randomSongListParameters, removeFavorite: removeFavoriteParameters, scrobble: scrobbleParameters, @@ -288,18 +513,35 @@ export const ssType = { similarSongs: similarSongsParameters, structuredLyrics: structuredLyricsParameters, topSongsList: topSongsListParameters, + updatePlaylist: updatePlaylistParameters, }, _response: { album, albumArtist, albumArtistList, albumList, + albumListEntry, artistInfo, + artistListEntry, authenticate, baseResponse, createFavorite, + createPlaylist, + genre, + getAlbum, + getAlbumList2, + getArtist, + getArtists, + getGenres, + getPlaylist, + getPlaylists, + getSong, + getSongsByGenre, + getStarred, musicFolderList, ping, + playlist, + playlistListEntry, randomSongList, removeFavorite, scrobble, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index b6bb79cf..860fb131 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1,3 +1,6 @@ +import orderBy from 'lodash/orderBy'; +import reverse from 'lodash/reverse'; +import shuffle from 'lodash/shuffle'; import { z } from 'zod'; import { ServerFeatures } from './features-types'; import { jfType } from './jellyfin/jellyfin-types'; @@ -128,7 +131,7 @@ export interface BasePaginatedResponse { error?: string | any; items: T; startIndex: number; - totalRecordCount: number; + totalRecordCount: number | null; } export type AuthenticationResponse = { @@ -309,6 +312,11 @@ type BaseEndpointArgs = { }; }; +export interface BaseQuery { + sortBy: T; + sortOrder: SortOrder; +} + // Genre List export type GenreListResponse = BasePaginatedResponse | null | undefined; @@ -318,7 +326,7 @@ export enum GenreListSort { NAME = 'name', } -export type GenreListQuery = { +export interface GenreListQuery extends BaseQuery { _custom?: { jellyfin?: null; navidrome?: null; @@ -326,10 +334,8 @@ export type GenreListQuery = { limit?: number; musicFolderId?: string; searchTerm?: string; - sortBy: GenreListSort; - sortOrder: SortOrder; startIndex: number; -}; +} type GenreListSortMap = { jellyfin: Record; @@ -370,22 +376,22 @@ export enum AlbumListSort { YEAR = 'year', } -export type AlbumListQuery = { +export interface AlbumListQuery extends BaseQuery { _custom?: { - jellyfin?: Partial> & { - maxYear?: number; - minYear?: number; - }; + jellyfin?: Partial>; navidrome?: Partial>; }; artistIds?: string[]; + compilation?: boolean; + favorite?: boolean; + genres?: string[]; limit?: number; + maxYear?: number; + minYear?: number; musicFolderId?: string; searchTerm?: string; - sortBy: AlbumListSort; - sortOrder: SortOrder; startIndex: number; -}; +} export type AlbumListArgs = { query: AlbumListQuery } & BaseEndpointArgs; @@ -481,24 +487,23 @@ export enum SongListSort { YEAR = 'year', } -export type SongListQuery = { +export interface SongListQuery extends BaseQuery { _custom?: { - jellyfin?: Partial> & { - maxYear?: number; - minYear?: number; - }; + jellyfin?: Partial>; navidrome?: Partial>; }; albumIds?: string[]; artistIds?: string[]; + favorite?: boolean; + genreIds?: string[]; imageSize?: number; limit?: number; + maxYear?: number; + minYear?: number; musicFolderId?: string; searchTerm?: string; - sortBy: SongListSort; - sortOrder: SortOrder; startIndex: number; -}; +} export type SongListArgs = { query: SongListQuery } & BaseEndpointArgs; @@ -595,7 +600,7 @@ export enum AlbumArtistListSort { SONG_COUNT = 'songCount', } -export type AlbumArtistListQuery = { +export interface AlbumArtistListQuery extends BaseQuery { _custom?: { jellyfin?: Partial>; navidrome?: Partial>; @@ -603,10 +608,8 @@ export type AlbumArtistListQuery = { limit?: number; musicFolderId?: string; searchTerm?: string; - sortBy: AlbumArtistListSort; - sortOrder: SortOrder; startIndex: number; -}; +} export type AlbumArtistListArgs = { query: AlbumArtistListQuery } & BaseEndpointArgs; @@ -683,17 +686,15 @@ export enum ArtistListSort { SONG_COUNT = 'songCount', } -export type ArtistListQuery = { +export interface ArtistListQuery extends BaseQuery { _custom?: { jellyfin?: Partial>; navidrome?: Partial>; }; limit?: number; musicFolderId?: string; - sortBy: ArtistListSort; - sortOrder: SortOrder; startIndex: number; -}; +} export type ArtistListArgs = { query: ArtistListQuery } & BaseEndpointArgs; @@ -879,17 +880,15 @@ export enum PlaylistListSort { UPDATED_AT = 'updatedAt', } -export type PlaylistListQuery = { +export interface PlaylistListQuery extends BaseQuery { _custom?: { jellyfin?: Partial>; navidrome?: Partial>; }; limit?: number; searchTerm?: string; - sortBy: PlaylistListSort; - sortOrder: SortOrder; startIndex: number; -}; +} export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs; @@ -963,7 +962,7 @@ export enum UserListSort { NAME = 'name', } -export type UserListQuery = { +export interface UserListQuery extends BaseQuery { _custom?: { navidrome?: { owner_id?: string; @@ -971,10 +970,8 @@ export type UserListQuery = { }; limit?: number; searchTerm?: string; - sortBy: UserListSort; - sortOrder: SortOrder; startIndex: number; -}; +} export type UserListArgs = { query: UserListQuery } & BaseEndpointArgs; @@ -1228,3 +1225,223 @@ export type TranscodingQuery = { export type TranscodingArgs = { query: TranscodingQuery; } & BaseEndpointArgs; + +export type ControllerEndpoint = { + addToPlaylist: (args: AddToPlaylistArgs) => Promise; + authenticate: ( + url: string, + body: { legacy?: boolean; password: string; username: string }, + ) => Promise; + 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; + // getArtistInfo?: (args: any) => void; + // getArtistList?: (args: ArtistListArgs) => Promise; + getDownloadUrl: (args: DownloadArgs) => string; + 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; + getServerInfo: (args: ServerInfoArgs) => Promise; + getSimilarSongs: (args: SimilarSongsArgs) => Promise; + getSongDetail: (args: SongDetailArgs) => Promise; + getSongList: (args: SongListArgs) => Promise; + getSongListCount: (args: SongListArgs) => Promise; + getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise; + getTopSongs: (args: TopSongListArgs) => Promise; + getTranscodingUrl: (args: TranscodingArgs) => string; + getUserList?: (args: UserListArgs) => Promise; + movePlaylistItem?: (args: MoveItemArgs) => Promise; + removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; + scrobble: (args: ScrobbleArgs) => Promise; + search: (args: SearchArgs) => Promise; + setRating?: (args: SetRatingArgs) => Promise; + shareItem?: (args: ShareItemArgs) => Promise; + updatePlaylist: (args: UpdatePlaylistArgs) => Promise; +}; + +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 50df66b4..6e7e3b11 100644 --- a/src/renderer/components/card/card-rows.tsx +++ b/src/renderer/components/card/card-rows.tsx @@ -294,7 +294,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 deac44e4..d16c24b3 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -7,7 +7,6 @@ import { IDatasource, PaginationChangedEvent, RowDoubleClickedEvent, - RowModelType, } from '@ag-grid-community/core'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { QueryKey, useQueryClient } from '@tanstack/react-query'; @@ -16,7 +15,12 @@ import orderBy from 'lodash/orderBy'; import { generatePath, useNavigate } from 'react-router'; import { api } from '/@/renderer/api'; import { QueryPagination, queryKeys } from '/@/renderer/api/query-keys'; -import { BasePaginatedResponse, LibraryItem, ServerListItem } from '/@/renderer/api/types'; +import { + BasePaginatedResponse, + BaseQuery, + LibraryItem, + ServerListItem, +} from '/@/renderer/api/types'; import { getColumnDefs, VirtualTableProps } from '/@/renderer/components/virtual-table'; import { SetContextMenuItems, useHandleTableContextMenu } from '/@/renderer/features/context-menu'; import { AppRoute } from '/@/renderer/router/routes'; @@ -34,6 +38,7 @@ interface UseAgGridProps { columnType?: 'albumDetail' | 'generic'; contextMenu: SetContextMenuItems; customFilters?: Partial; + isClientSide?: boolean; isClientSideSort?: boolean; isSearchParams?: boolean; itemCount?: number; @@ -43,7 +48,9 @@ interface UseAgGridProps { tableRef: MutableRefObject; } -export const useVirtualTable = ({ +const BLOCK_SIZE = 500; + +export const useVirtualTable = >({ server, tableRef, pageKey, @@ -52,13 +59,14 @@ export const useVirtualTable = ({ itemCount, customFilters, isSearchParams, + isClientSide, isClientSideSort, columnType, }: UseAgGridProps) => { const queryClient = useQueryClient(); const navigate = useNavigate(); const { setTable, setTablePagination } = useListStoreActions(); - const properties = useListStoreByKey({ filter: customFilters, key: pageKey }); + const properties = useListStoreByKey({ filter: customFilters, key: pageKey }); const [searchParams, setSearchParams] = useSearchParams(); const scrollOffset = searchParams.get('scrollOffset'); @@ -182,6 +190,19 @@ export const useVirtualTable = ({ return; } + if (results.totalRecordCount === null) { + const hasMoreRows = results?.items?.length === BLOCK_SIZE; + const lastRowIndex = hasMoreRows + ? undefined + : params.startRow + results.items.length; + + params.successCallback( + results?.items || [], + hasMoreRows ? undefined : lastRowIndex, + ); + return; + } + params.successCallback(results?.items || [], results?.totalRecordCount || 0); }, rowCount: undefined, @@ -321,6 +342,7 @@ export const useVirtualTable = ({ alwaysShowHorizontalScroll: true, autoFitColumns: properties.table.autoFit, blockLoadDebounceMillis: 200, + cacheBlockSize: BLOCK_SIZE, getRowId: (data: GetRowIdParams) => data.data.id, infiniteInitialRowCount: itemCount || 100, pagination: isPaginationEnabled, @@ -335,10 +357,11 @@ export const useVirtualTable = ({ : undefined, rowBuffer: 20, rowHeight: properties.table.rowHeight || 40, - rowModelType: 'infinite' as RowModelType, + rowModelType: isClientSide ? 'clientSide' : 'infinite', suppressRowDrag: true, }; }, [ + isClientSide, isPaginationEnabled, isSearchParams, itemCount, @@ -370,7 +393,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-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index ab6ca368..5ebd2150 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -11,7 +11,13 @@ import { generatePath, useParams } from 'react-router'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types'; +import { + AlbumListQuery, + AlbumListSort, + LibraryItem, + QueueSong, + SortOrder, +} from '/@/renderer/api/types'; import { Button, Popover, Spoiler } from '/@/renderer/components'; import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel'; import { @@ -164,13 +170,12 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP query: { _custom: { jellyfin: { - AlbumArtistIds: detailQuery?.data?.albumArtists[0]?.id, ExcludeItemIds: detailQuery?.data?.id, }, - navidrome: { - artist_id: detailQuery?.data?.albumArtists[0]?.id, - }, }, + artistIds: detailQuery?.data?.albumArtists.length + ? [detailQuery?.data?.albumArtists[0].id] + : undefined, limit: 15, sortBy: AlbumListSort.YEAR, sortOrder: SortOrder.DESC, @@ -179,15 +184,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP serverId: server?.id, }); - const relatedAlbumGenresRequest = { - _custom: { - jellyfin: { - GenreIds: detailQuery?.data?.genres?.[0]?.id, - }, - navidrome: { - genre_id: detailQuery?.data?.genres?.[0]?.id, - }, - }, + const relatedAlbumGenresRequest: AlbumListQuery = { + genres: detailQuery.data?.genres.length ? [detailQuery.data.genres[0].id] : undefined, limit: 15, sortBy: AlbumListSort.RANDOM, sortOrder: SortOrder.ASC, diff --git a/src/renderer/features/albums/components/album-list-grid-view.tsx b/src/renderer/features/albums/components/album-list-grid-view.tsx index 86bbe461..1f96665b 100644 --- a/src/renderer/features/albums/components/album-list-grid-view.tsx +++ b/src/renderer/features/albums/components/album-list-grid-view.tsx @@ -29,7 +29,7 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => { const server = useCurrentServer(); const handlePlayQueueAdd = usePlayQueueAdd(); const { pageKey, customFilters, id } = useListContext(); - const { grid, display, filter } = useListStoreByKey({ key: pageKey }); + const { grid, display, filter } = useListStoreByKey({ key: pageKey }); const { setGrid } = useListStoreActions(); const [searchParams, setSearchParams] = useSearchParams(); @@ -162,9 +162,9 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => { const query: AlbumListQuery = { limit: take, - startIndex: skip, ...filter, ...customFilters, + startIndex: skip, }; const queryKey = queryKeys.albums.list(server?.id || '', query, id); 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 85d4168d..156675a6 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -15,13 +15,20 @@ import { RiSettings3Fill, } from 'react-icons/ri'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { AlbumListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types'; +import { + AlbumListQuery, + AlbumListSort, + LibraryItem, + ServerType, + SortOrder, +} from '/@/renderer/api/types'; import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; 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,26 +146,74 @@ 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, + itemCount, + tableRef, +}: AlbumListHeaderFiltersProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); const { pageKey, customFilters, handlePlay } = useListContext(); const server = useCurrentServer(); const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions(); - const { display, filter, table, grid } = useListStoreByKey({ + const { display, filter, table, grid } = useListStoreByKey({ filter: customFilters, key: pageKey, }); const cq = useContainerQuery(); const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.ALBUM, server, }); @@ -191,27 +246,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', }); @@ -347,8 +410,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.genres?.length || filter.favorite); + + return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied; + }, [ + filter?._custom?.jellyfin, + filter?._custom?.navidrome, + filter.favorite, + filter.genres?.length, + 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 9e503270..58265056 100644 --- a/src/renderer/features/albums/components/album-list-header.tsx +++ b/src/renderer/features/albums/components/album-list-header.tsx @@ -3,7 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { Flex, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { useTranslation } from 'react-i18next'; -import { LibraryItem } from '/@/renderer/api/types'; +import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types'; import { PageHeader, SearchInput } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters'; @@ -33,8 +33,9 @@ export const AlbumListHeader = ({ const cq = useContainerQuery(); const playButtonBehavior = usePlayButtonBehavior(); const genreRef = useRef(); - const { filter, handlePlay, refresh, search } = useDisplayRefresh({ + const { filter, handlePlay, refresh, search } = useDisplayRefresh({ gridRef, + itemCount, itemType: LibraryItem.ALBUM, server, tableRef, @@ -90,6 +91,7 @@ export const AlbumListHeader = ({ diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index d1133f2b..5d51258a 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -3,7 +3,13 @@ import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { useTranslation } from 'react-i18next'; import { useListFilterByKey } from '../../../store/list.store'; -import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { + AlbumArtistListSort, + AlbumListQuery, + GenreListSort, + LibraryItem, + SortOrder, +} from '/@/renderer/api/types'; import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components'; import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; import { useGenreList } from '/@/renderer/features/genres'; @@ -25,7 +31,7 @@ export const JellyfinAlbumFilters = ({ serverId, }: JellyfinAlbumFiltersProps) => { const { t } = useTranslation(); - const filter = useListFilterByKey({ key: pageKey }); + const filter = useListFilterByKey({ key: pageKey }); const { setFilter } = useListStoreActions(); // TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library @@ -47,10 +53,6 @@ export const JellyfinAlbumFilters = ({ })); }, [genreListQuery.data]); - const selectedGenres = useMemo(() => { - return filter?._custom?.jellyfin?.GenreIds?.split(','); - }, [filter?._custom?.jellyfin?.GenreIds]); - const toggleFilters = [ { label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), @@ -58,20 +60,15 @@ export const JellyfinAlbumFilters = ({ const updatedFilters = setFilter({ customFilters, data: { - _custom: { - ...filter?._custom, - jellyfin: { - ...filter?._custom?.jellyfin, - IsFavorite: e.currentTarget.checked ? true : undefined, - }, - }, + _custom: filter?._custom, + favorite: e.currentTarget.checked ? true : undefined, }, itemType: LibraryItem.ALBUM, key: pageKey, }) as AlbumListFilter; onFilterChange(updatedFilters); }, - value: filter?._custom?.jellyfin?.IsFavorite, + value: filter?.favorite, }, ]; @@ -80,13 +77,8 @@ export const JellyfinAlbumFilters = ({ const updatedFilters = setFilter({ customFilters, data: { - _custom: { - ...filter?._custom, - jellyfin: { - ...filter?._custom?.jellyfin, - minYear: e === '' ? undefined : (e as number), - }, - }, + _custom: filter?._custom, + minYear: e === '' ? undefined : (e as number), }, itemType: LibraryItem.ALBUM, key: pageKey, @@ -99,13 +91,8 @@ export const JellyfinAlbumFilters = ({ const updatedFilters = setFilter({ customFilters, data: { - _custom: { - ...filter?._custom, - jellyfin: { - ...filter?._custom?.jellyfin, - maxYear: e === '' ? undefined : (e as number), - }, - }, + _custom: filter?._custom, + maxYear: e === '' ? undefined : (e as number), }, itemType: LibraryItem.ALBUM, key: pageKey, @@ -114,17 +101,11 @@ export const JellyfinAlbumFilters = ({ }, 500); const handleGenresFilter = debounce((e: string[] | undefined) => { - const genreFilterString = e?.length ? e.join(',') : undefined; const updatedFilters = setFilter({ customFilters, data: { - _custom: { - ...filter?._custom, - jellyfin: { - ...filter?._custom?.jellyfin, - GenreIds: genreFilterString, - }, - }, + _custom: filter?._custom, + genres: e, }, itemType: LibraryItem.ALBUM, key: pageKey, @@ -157,17 +138,11 @@ export const JellyfinAlbumFilters = ({ }, [albumArtistListQuery?.data?.items]); const handleAlbumArtistFilter = (e: string[] | null) => { - const albumArtistFilterString = e?.length ? e.join(',') : undefined; const updatedFilters = setFilter({ customFilters, data: { - _custom: { - ...filter?._custom, - jellyfin: { - ...filter?._custom?.jellyfin, - AlbumArtistIds: albumArtistFilterString, - }, - }, + _custom: filter?._custom, + artistIds: e || undefined, }, itemType: LibraryItem.ALBUM, key: pageKey, @@ -193,21 +168,21 @@ export const JellyfinAlbumFilters = ({ handleMinYearFilter(e)} /> handleMaxYearFilter(e)} /> @@ -216,7 +191,7 @@ export const JellyfinAlbumFilters = ({ clearable searchable data={genreList} - defaultValue={selectedGenres} + defaultValue={filter.genres} label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })} onChange={handleGenresFilter} /> diff --git a/src/renderer/features/albums/components/navidrome-album-filters.tsx b/src/renderer/features/albums/components/navidrome-album-filters.tsx index efb39f61..77b9a92c 100644 --- a/src/renderer/features/albums/components/navidrome-album-filters.tsx +++ b/src/renderer/features/albums/components/navidrome-album-filters.tsx @@ -5,7 +5,13 @@ import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/rend import debounce from 'lodash/debounce'; import { useGenreList } from '/@/renderer/features/genres'; import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; -import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { + AlbumArtistListSort, + AlbumListQuery, + GenreListSort, + LibraryItem, + SortOrder, +} from '/@/renderer/api/types'; import { useTranslation } from 'react-i18next'; interface NavidromeAlbumFiltersProps { @@ -24,7 +30,7 @@ export const NavidromeAlbumFilters = ({ serverId, }: NavidromeAlbumFiltersProps) => { const { t } = useTranslation(); - const { filter } = useListStoreByKey({ key: pageKey }); + const { filter } = useListStoreByKey({ key: pageKey }); const { setFilter } = useListStoreActions(); const genreListQuery = useGenreList({ @@ -48,13 +54,8 @@ export const NavidromeAlbumFilters = ({ const updatedFilters = setFilter({ customFilters, data: { - _custom: { - ...filter._custom, - navidrome: { - ...filter._custom?.navidrome, - genre_id: e || undefined, - }, - }, + _custom: filter._custom, + genres: e ? [e] : undefined, }, itemType: LibraryItem.ALBUM, key: pageKey, @@ -90,20 +91,15 @@ export const NavidromeAlbumFilters = ({ const updatedFilters = setFilter({ customFilters, data: { - _custom: { - ...filter._custom, - navidrome: { - ...filter._custom?.navidrome, - starred: e.currentTarget.checked ? true : undefined, - }, - }, + _custom: filter._custom, + favorite: e.currentTarget.checked ? true : undefined, }, itemType: LibraryItem.ALBUM, key: pageKey, }) as AlbumListFilter; onFilterChange(updatedFilters); }, - value: filter._custom?.navidrome?.starred, + value: filter.favorite, }, { label: t('filter.isCompilation', { postProcess: 'sentenceCase' }), @@ -111,20 +107,15 @@ export const NavidromeAlbumFilters = ({ const updatedFilters = setFilter({ customFilters, data: { - _custom: { - ...filter._custom, - navidrome: { - ...filter._custom?.navidrome, - compilation: e.currentTarget.checked ? true : undefined, - }, - }, + _custom: filter._custom, + compilation: e.currentTarget.checked ? true : undefined, }, itemType: LibraryItem.ALBUM, key: pageKey, }) as AlbumListFilter; onFilterChange(updatedFilters); }, - value: filter._custom?.navidrome?.compilation, + value: filter.compilation, }, { label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }), 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 00000000..bd1eb57d --- /dev/null +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -0,0 +1,141 @@ +import { Divider, Group, Stack } from '@mantine/core'; +import debounce from 'lodash/debounce'; +import { ChangeEvent, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlbumListQuery, 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: { + genres: e ? [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: { + favorite: e.target.checked ? true : undefined, + }, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + onFilterChange(updatedFilters); + }, + value: filter.favorite, + }, + ]; + + const handleYearFilter = debounce((e: number | string, type: 'min' | 'max') => { + let data: Partial = {}; + + if (type === 'min') { + data = { + minYear: e ? Number(e) : undefined, + }; + } else { + data = { + maxYear: e ? Number(e) : undefined, + }; + } + + 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 00000000..849708a6 --- /dev/null +++ b/src/renderer/features/songs/queries/song-list-count-query.ts @@ -0,0 +1,30 @@ +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 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 || '', + Object.keys(query).length === 0 ? undefined : 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 c60d3575..99266318 100644 --- a/src/renderer/features/songs/routes/song-list-route.tsx +++ b/src/renderer/features/songs/routes/song-list-route.tsx @@ -10,11 +10,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 { t } = useTranslation(); @@ -30,14 +30,7 @@ const TrackListRoute = () => { const value = { ...(albumArtistId && { artistIds: [albumArtistId] }), ...(genreId && { - _custom: { - jellyfin: { - GenreIds: genreId, - }, - navidrome: { - genre_id: genreId, - }, - }, + genreIds: [genreId], }), }; @@ -76,29 +69,22 @@ const TrackListRoute = () => { return genre?.name; }, [genreId, genreList.data]); - const itemCountCheck = useSongList({ + const itemCountCheck = useSongListCount({ options: { cacheTime: 1000 * 60, staleTime: 1000 * 60, }, - query: { - limit: 1, - startIndex: 0, - ...songListFilter, - }, + query: songListFilter, 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 }) => { if (!itemCount || itemCount === 0) return; const { initialSongId, playType } = args; - const query: SongListQuery = { startIndex: 0, ...songListFilter }; + const query: SongListQuery = { ...songListFilter, limit: itemCount, startIndex: 0 }; if (albumArtistId) { handlePlayQueueAdd?.({ diff --git a/src/renderer/hooks/use-display-refresh.ts b/src/renderer/hooks/use-display-refresh.ts index c969d0b4..f48b486b 100644 --- a/src/renderer/hooks/use-display-refresh.ts +++ b/src/renderer/hooks/use-display-refresh.ts @@ -11,21 +11,24 @@ import { useListStoreActions, useListStoreByKey } from '/@/renderer/store'; export type UseDisplayRefreshProps = { gridRef: MutableRefObject; + itemCount?: number; tableRef: MutableRefObject; } & UseHandleListFilterChangeProps; -export const useDisplayRefresh = ({ +export const useDisplayRefresh = ({ isClientSideSort, + itemCount, gridRef, itemType, server, tableRef, }: UseDisplayRefreshProps) => { const { customFilters, pageKey, handlePlay } = useListContext(); - const { display, filter } = useListStoreByKey({ key: pageKey }); + const { display, filter } = useListStoreByKey({ key: pageKey }); const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ isClientSideSort, + itemCount, itemType, server, }); diff --git a/src/renderer/hooks/use-list-filter-refresh.ts b/src/renderer/hooks/use-list-filter-refresh.ts index 0aa8cf46..a0dbdae8 100644 --- a/src/renderer/hooks/use-list-filter-refresh.ts +++ b/src/renderer/hooks/use-list-filter-refresh.ts @@ -10,12 +10,16 @@ import orderBy from 'lodash/orderBy'; export interface UseHandleListFilterChangeProps { isClientSideSort?: boolean; + itemCount?: number; itemType: LibraryItem; server: ServerListItem | null; } +const BLOCK_SIZE = 500; + export const useListFilterRefresh = ({ server, + itemCount, itemType, isClientSideSort, }: UseHandleListFilterChangeProps) => { @@ -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: { @@ -91,18 +95,34 @@ export const useListFilterRefresh = ({ queryKey, }); - 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; } - params.successCallback(res?.items || [], res?.totalRecordCount || 0); + 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(results?.items || [], results?.totalRecordCount || 0); }, rowCount: undefined, @@ -112,7 +132,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 49602e51..358f1c54 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -17,10 +17,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'), ); @@ -163,11 +159,6 @@ export const AppRouter = () => { errorElement={} path={AppRoute.PLAYLISTS} /> - } - errorElement={} - path={AppRoute.PLAYLISTS_DETAIL} - /> } errorElement={} diff --git a/src/renderer/router/routes.ts b/src/renderer/router/routes.ts index 26129838..1155d252 100644 --- a/src/renderer/router/routes.ts +++ b/src/renderer/router/routes.ts @@ -20,7 +20,6 @@ export enum AppRoute { NOW_PLAYING = '/now-playing', PLAYING = '/playing', PLAYLISTS = '/playlists', - PLAYLISTS_DETAIL = '/playlists/:playlistId', PLAYLISTS_DETAIL_SONGS = '/playlists/:playlistId/songs', SEARCH = '/search/:itemType', SERVERS = '/servers', diff --git a/src/renderer/store/list.store.ts b/src/renderer/store/list.store.ts index f9d7ff0b..9eeeb2fe 100644 --- a/src/renderer/store/list.store.ts +++ b/src/renderer/store/list.store.ts @@ -627,7 +627,10 @@ export const useListStore = create()( export const useListStoreActions = () => useListStore((state) => state._actions); -export const useListStoreByKey = (args: { filter?: Partial; key: string }) => { +export const useListStoreByKey = (args: { + filter?: Partial; + key: string; +}): ListItemProps => { const key = args.key as keyof ListState['item']; return useListStore( (state) => ({ @@ -644,7 +647,7 @@ export const useListStoreByKey = (args: { filter?: Partial; ke export const useListFilterByKey = (args: { filter?: Partial | any; key: string; -}) => { +}): TFilter => { const key = args.key as keyof ListState['item']; return useListStore( (state) => { diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index dc03bda5..2c0a1dca 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -224,7 +224,6 @@ export interface SettingsState { albumBackgroundBlur: number; artistItems: SortableItem[]; buttonSize: number; - defaultFullPlaylist: boolean; disabledContextMenu: { [k in ContextMenuItemType]?: boolean }; doubleClickQueueAll: boolean; externalLinks: boolean; @@ -370,7 +369,6 @@ const initialState: SettingsState = { albumBackgroundBlur: 6, artistItems, buttonSize: 20, - defaultFullPlaylist: true, disabledContextMenu: {}, doubleClickQueueAll: true, externalLinks: true,