From 9cebb53083a1066786df0afecb792b371b92c3a4 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Wed, 18 Sep 2024 21:16:20 -0700 Subject: [PATCH 1/8] to const functs --- src/renderer/api/controller.ts | 210 +- .../api/jellyfin/jellyfin-controller.ts | 1722 ++++++++--------- .../api/navidrome/navidrome-controller.ts | 1139 +++++------ .../api/subsonic/subsonic-controller.ts | 766 ++++---- src/renderer/api/types.ts | 41 + 5 files changed, 1746 insertions(+), 2132 deletions(-) diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index cc3e3037..1b4f556c 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -22,98 +22,25 @@ import type { 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, + ControllerEndpoint, } 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 { RandomSongListArgs } from './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,130 +48,9 @@ type ApiController = { }; const endpoints: ApiController = { - jellyfin: { - addToPlaylist: jfController.addToPlaylist, - authenticate: jfController.authenticate, - clearPlaylist: undefined, - createFavorite: jfController.createFavorite, - createPlaylist: jfController.createPlaylist, - deleteFavorite: jfController.deleteFavorite, - deletePlaylist: jfController.deletePlaylist, - getAlbumArtistDetail: jfController.getAlbumArtistDetail, - getAlbumArtistList: jfController.getAlbumArtistList, - getAlbumDetail: jfController.getAlbumDetail, - getAlbumList: jfController.getAlbumList, - getArtistDetail: undefined, - getArtistInfo: undefined, - getArtistList: undefined, - 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 as ControllerEndpoint, }; const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => { diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 96a21cb8..0243490b 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,785 +22,656 @@ 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', - }, - }); +// 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 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', +const VERSION_INFO: VersionInfo = [ + [ + '10.9.0', + { + [ServerFeature.LYRICS_SINGLE_STRUCTURED]: [1], + [ServerFeature.PUBLIC_PLAYLIST]: [1], }, - }); - - 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); -}; +export const JellyfinController: ControllerEndpoint = { + addToPlaylist: async (args) => { + const { query, body, apiClientProps } = args; -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)); + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); } - } - - 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; + 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; + }, + authenticate: async (url, body) => { + const cleanServerUrl = url.replace(/\/$/, ''); - 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, - }, - }); + 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 get top song list'); - } + if (res.status !== 200) { + throw new Error('Failed to authenticate'); + } - return { - items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), - startIndex: 0, - totalRecordCount: res.body.TotalRecordCount, - }; -}; + return { + credential: res.body.AccessToken, + userId: res.body.User.Id, + username: res.body.User.Name, + }; + }, + createFavorite: async (args) => { + const { query, apiClientProps } = args; -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)); + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); } - } - 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, - }, - }); + for (const id of query.id) { + await jfApiClient(apiClientProps).createFavorite({ + body: {}, + params: { + id, + userId: apiClientProps.server?.userId, + }, + }); + } - if (res.status !== 200) { - throw new Error('Failed to get song list'); - } + return null; + }, + createPlaylist: async (args) => { + const { body, apiClientProps } = args; - let items: z.infer[]; + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - // 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 res = await jfApiClient(apiClientProps).createPlaylist({ + body: { + IsPublic: body.public, + MediaType: 'Audio', + Name: body.name, + UserId: apiClientProps.server.userId, + }, + }); - if (items.length < res.body.Items.length) { - res.body.TotalRecordCount -= res.body.Items.length - items.length; + if (res.status !== 200) { + throw new Error('Failed to create playlist'); } - } 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; + return { + id: res.body.Id, + }; + }, + deleteFavorite: async (args) => { + const { query, apiClientProps } = args; -const addToPlaylist = async (args: AddToPlaylistArgs): Promise => { - const { query, body, apiClientProps } = args; + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - 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, + }, + }); + } - const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD); + return null; + }, + deletePlaylist: async (args) => { + const { query, apiClientProps } = args; - for (const chunk of chunks) { - const res = await jfApiClient(apiClientProps).addToPlaylist({ + const res = await jfApiClient(apiClientProps).deletePlaylist({ 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'); + throw new Error('Failed to delete playlist'); } - } - return null; -}; + return null; + }, + getAlbumArtistDetail: async (args) => { + const { query, apiClientProps } = args; -const removeFromPlaylist = async ( - args: RemoveFromPlaylistArgs, -): Promise => { - const { query, apiClientProps } = args; + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - const chunks = chunk(query.songId, MAX_ITEMS_PER_PLAYLIST_ADD); + const res = await jfApiClient(apiClientProps).getAlbumArtistDetail({ + params: { + id: query.id, + userId: apiClientProps.server?.userId, + }, + query: { + Fields: 'Genres, Overview', + }, + }); - for (const chunk of chunks) { - const res = await jfApiClient(apiClientProps).removeFromPlaylist({ - body: null, + const similarArtistsRes = await jfApiClient(apiClientProps).getSimilarArtistList({ params: { id: query.id, }, query: { - EntryIds: chunk.join(','), + Limit: 10, }, }); - if (res.status !== 204) { - throw new Error('Failed to remove from playlist'); + if (res.status !== 200 || similarArtistsRes.status !== 200) { + throw new Error('Failed to get album artist detail'); } - } - return null; -}; + return jfNormalize.albumArtist( + { ...res.body, similarArtists: similarArtistsRes.body }, + apiClientProps.server, + ); + }, + getAlbumArtistList: async (args) => { + const { query, apiClientProps } = args; -const getPlaylistDetail = async (args: PlaylistDetailArgs): 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 (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } + if (res.status !== 200) { + throw new Error('Failed to get album artist list'); + } - const res = await jfApiClient(apiClientProps).getPlaylistDetail({ - params: { - id: query.id, - userId: apiClientProps.server?.userId, - }, - query: { - Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId', - Ids: query.id, - }, - }); + return { + items: res.body.Items.map((item) => + jfNormalize.albumArtist(item, apiClientProps.server), + ), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; + }, + getAlbumDetail: async (args) => { + const { query, apiClientProps } = args; - if (res.status !== 200) { - throw new Error('Failed to get playlist detail'); - } + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - return jfNormalize.playlist(res.body, apiClientProps.server); -}; + const res = await jfApiClient(apiClientProps).getAlbumDetail({ + params: { + id: query.id, + userId: apiClientProps.server.userId, + }, + query: { + Fields: 'Genres, DateCreated, ChildCount', + }, + }); -const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise => { - const { query, apiClientProps } = args; + 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 (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } + if (res.status !== 200 || songsRes.status !== 200) { + throw new Error('Failed to get album detail'); + } - 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, - }, - }); + return jfNormalize.album( + { ...res.body, Songs: songsRes.body.Items }, + apiClientProps.server, + ); + }, + getAlbumList: async (args) => { + const { query, apiClientProps } = args; - if (res.status !== 200) { - throw new Error('Failed to get playlist song list'); - } + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - return { - items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), - startIndex: query.startIndex, - totalRecordCount: res.body.TotalRecordCount, - }; -}; + 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 getPlaylistList = async (args: PlaylistListArgs): Promise => { - const { query, apiClientProps } = args; + const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined; - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } + 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, + }, + }); - 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 album list'); + } - if (res.status !== 200) { - throw new Error('Failed to get playlist list'); - } + return { + items: res.body.Items.map((item) => jfNormalize.album(item, apiClientProps.server)), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; + }, + getDownloadUrl: (args) => { + const { apiClientProps, query } = args; - return { - items: res.body.Items.map((item) => jfNormalize.playlist(item, apiClientProps.server)), - startIndex: 0, - totalRecordCount: res.body.TotalRecordCount, - }; -}; + return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`; + }, + getGenreList: async (args) => { + const { apiClientProps, query } = args; -const createPlaylist = async (args: CreatePlaylistArgs): Promise => { - const { body, apiClientProps } = args; + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - 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, + }, + }); - 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 get genre list'); + } - if (res.status !== 200) { - throw new Error('Failed to create playlist'); - } + 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; - return { - id: res.body.Id, - }; -}; + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } -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, - }, - }); + const res = await jfApiClient(apiClientProps).getSongLyrics({ + params: { + id: query.songId, + }, + }); - if (res.status !== 204) { - throw new Error('Failed to update playlist'); - } + if (res.status !== 200) { + throw new Error('Failed to get lyrics'); + } - return null; -}; + if (res.body.Lyrics.length > 0 && res.body.Lyrics[0].Start === undefined) { + return res.body.Lyrics.map((lyric) => lyric.Text).join('\n'); + } -const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { - const { query, apiClientProps } = args; + return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]); + }, + getMusicFolderList: async (args) => { + const { apiClientProps } = args; + const userId = apiClientProps.server?.userId; - const res = await jfApiClient(apiClientProps).deletePlaylist({ - body: null, - params: { - id: query.id, - }, - }); + if (!userId) throw new Error('No userId found'); - if (res.status !== 204) { - throw new Error('Failed to delete playlist'); - } + const res = await jfApiClient(apiClientProps).getMusicFolderList({ + params: { + userId, + }, + }); - return null; -}; + 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, + ); -const createFavorite = async (args: FavoriteArgs): Promise => { - const { query, apiClientProps } = args; + 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'); - } + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - for (const id of query.id) { - await jfApiClient(apiClientProps).createFavorite({ - body: {}, + const res = await jfApiClient(apiClientProps).getPlaylistDetail({ params: { - id, + id: query.id, userId: apiClientProps.server?.userId, }, + query: { + Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId', + Ids: query.id, + }, }); - } - return null; -}; + if (res.status !== 200) { + throw new Error('Failed to get playlist detail'); + } -const deleteFavorite = async (args: FavoriteArgs): Promise => { - const { query, apiClientProps } = args; + return jfNormalize.playlist(res.body, apiClientProps.server); + }, + getPlaylistList: async (args) => { + const { query, apiClientProps } = args; - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - for (const id of query.id) { - await jfApiClient(apiClientProps).removeFavorite({ - body: {}, + const res = await jfApiClient(apiClientProps).getPlaylistList({ params: { - id, 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, + }, }); - } - return null; -}; + if (res.status !== 200) { + throw new Error('Failed to get playlist list'); + } -const scrobble = async (args: ScrobbleArgs): Promise => { - const { query, apiClientProps } = args; + return { + items: res.body.Items.map((item) => jfNormalize.playlist(item, apiClientProps.server)), + startIndex: 0, + totalRecordCount: res.body.TotalRecordCount, + }; + }, + getPlaylistSongList: async (args) => { + const { query, apiClientProps } = args; - const position = query.position && Math.round(query.position); + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - 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 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, }, }); - return null; - } + if (res.status !== 200) { + throw new Error('Failed to get playlist song list'); + } - if (query.event === 'start') { - jfApiClient(apiClientProps).scrobblePlaying({ - body: { - ItemId: query.id, - PositionTicks: position, - }, - }); + 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; - return null; - } + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - if (query.event === 'pause') { - jfApiClient(apiClientProps).scrobbleProgress({ - body: { - EventName: query.event, - IsPaused: true, - ItemId: query.id, - PositionTicks: position, - }, - }); + const yearsGroup = []; + if (query.minYear && query.maxYear) { + for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) { + yearsGroup.push(String(i)); + } + } - return null; - } + const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined; - if (query.event === 'unpause') { - jfApiClient(apiClientProps).scrobbleProgress({ - body: { - EventName: query.event, - IsPaused: false, - ItemId: query.id, - PositionTicks: position, + 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, }, }); - return null; - } + if (res.status !== 200) { + throw new Error('Failed to get random songs'); + } - jfApiClient(apiClientProps).scrobbleProgress({ - body: { - ItemId: query.id, - PositionTicks: position, - }, - }); + 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; - return null; -}; + const res = await jfApiClient(apiClientProps).getServerInfo(); -const search = async (args: SearchArgs): Promise => { - const { query, apiClientProps } = args; + if (res.status !== 200) { + throw new Error('Failed to get server info'); + } - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } + const features = getFeatures(VERSION_INFO, res.body.Version); - let albums: z.infer['Items'] = []; - let albumArtists: z.infer['Items'] = []; - let songs: z.infer['Items'] = []; + return { + features, + id: apiClientProps.server?.id, + version: res.body.Version, + }; + }, + getSimilarSongs: async (args) => { + const { apiClientProps, query } = args; - if (query.albumLimit) { - const res = await jfApiClient(apiClientProps).getAlbumList({ + // Prefer getSimilarSongs, where possible. Fallback to InstantMix + // where no similar songs were found. + const res = await jfApiClient(apiClientProps).getSimilarSongs({ params: { - userId: apiClientProps.server?.userId, + itemId: query.songId, }, query: { - EnableTotalRecordCount: true, - ImageTypeLimit: 1, - IncludeItemTypes: 'MusicAlbum', - Limit: query.albumLimit, - Recursive: true, - SearchTerm: query.query, - SortBy: 'SortName', - SortOrder: 'Ascending', - StartIndex: query.albumStartIndex || 0, + Fields: 'Genres, DateCreated, MediaSources, ParentId', + Limit: query.count, + UserId: apiClientProps.server?.userId || undefined, }, }); - if (res.status !== 200) { - throw new Error('Failed to get album list'); - } + 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, '')); + } - albums = res.body.Items; - } + return acc; + }, []); - if (query.albumArtistLimit) { - const res = await jfApiClient(apiClientProps).getAlbumArtistList({ + if (results.length > 0) { + return results; + } + } + + const mix = await jfApiClient(apiClientProps).getInstantMix({ + params: { + itemId: query.songId, + }, 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, + 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 album artist list'); + throw new Error('Failed to get song detail'); } - albumArtists = res.body.Items; - } + 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._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; - if (query.songLimit) { const res = await jfApiClient(apiClientProps).getSongList({ params: { userId: apiClientProps.server?.userId, }, query: { - EnableTotalRecordCount: true, + AlbumIds: albumIdsFilter, + ArtistIds: artistIdsFilter, Fields: 'Genres, DateCreated, MediaSources, ParentId', IncludeItemTypes: 'Audio', - Limit: query.songLimit, + Limit: query.limit, + ParentId: query.musicFolderId, Recursive: true, - SearchTerm: query.query, - SortBy: 'Album,SortName', - SortOrder: 'Ascending', - StartIndex: query.songStartIndex || 0, - UserId: apiClientProps.server?.userId, + 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 +679,325 @@ 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; - - 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 (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, - }; -}; -const getLyrics = async (args: LyricsArgs): Promise => { - const { query, apiClientProps } = args; - - if (!apiClientProps.server?.userId) { - throw new Error('No userId found'); - } - - const res = await jfApiClient(apiClientProps).getSongLyrics({ - params: { - id: query.songId, - }, - }); + return { + items: items.map((item) => + jfNormalize.song(item, apiClientProps.server, '', query.imageSize), + ), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; + }, + getTopSongs: async (args) => { + const { apiClientProps, query } = args; - if (res.status !== 200) { - throw new Error('Failed to get lyrics'); - } + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - if (res.body.Lyrics.length > 0 && res.body.Lyrics[0].Start === undefined) { - return res.body.Lyrics.map((lyric) => lyric.Text).join('\n'); - } + 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 res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]); -}; + if (res.status !== 200) { + throw new Error('Failed to get top song list'); + } -const getSongDetail = async (args: SongDetailArgs): 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}`; + } - const res = await jfApiClient(apiClientProps).getSongDetail({ - params: { - id: query.id, - userId: apiClientProps.server?.userId ?? '', - }, - }); + return url; + }, + movePlaylistItem: async (args) => { + const { apiClientProps, query } = args; - if (res.status !== 200) { - throw new Error('Failed to get song detail'); - } + const res = await jfApiClient(apiClientProps).movePlaylistItem({ + body: null, + params: { + itemId: query.trackId, + newIdx: query.endingIndex.toString(), + playlistId: query.playlistId, + }, + }); - return jfNormalize.song(res.body, apiClientProps.server, ''); -}; + if (res.status !== 204) { + throw new Error('Failed to move item in playlist'); + } + }, + removeFromPlaylist: async (args) => { + const { query, apiClientProps } = args; + + 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'); + } + } -const VERSION_INFO: VersionInfo = [ - [ - '10.9.0', - { [ServerFeature.LYRICS_SINGLE_STRUCTURED]: [1], [ServerFeature.PUBLIC_PLAYLIST]: [1] }, - ], -]; + return null; + }, + scrobble: async (args) => { + 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; + } -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, + }, + }); + + return null; + } - const features = getFeatures(VERSION_INFO, res.body.Version); + if (query.event === 'unpause') { + jfApiClient(apiClientProps).scrobbleProgress({ + body: { + EventName: query.event, + IsPaused: false, + ItemId: query.id, + PositionTicks: position, + }, + }); + + return null; + } - return { - features, - id: apiClientProps.server?.id, - version: res.body.Version, - }; -}; + jfApiClient(apiClientProps).scrobbleProgress({ + body: { + ItemId: query.id, + PositionTicks: position, + }, + }); -const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { - const { apiClientProps, query } = args; + return null; + }, + search: async (args) => { + const { query, apiClientProps } = 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 (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } - 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, '')); + 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; + albums = res.body.Items; } - } - 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'); - } + 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'); + } - return mix.body.Items.reduce((acc, song) => { - if (song.Id !== query.songId) { - acc.push(jfNormalize.song(song, apiClientProps.server, '')); + albumArtists = res.body.Items; } - return acc; - }, []); -}; + 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'); + } -const movePlaylistItem = async (args: MoveItemArgs): Promise => { - const { apiClientProps, query } = args; + songs = res.body.Items; + } - const res = await jfApiClient(apiClientProps).movePlaylistItem({ - body: null, - params: { - itemId: query.trackId, - newIdx: query.endingIndex.toString(), - playlistId: query.playlistId, - }, - }); + 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 (res.status !== 204) { - throw new Error('Failed to move item in playlist'); - } -}; + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } -const getDownloadUrl = (args: DownloadArgs) => { - const { apiClientProps, query } = args; + 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, + }, + }); - return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`; -}; + if (res.status !== 204) { + throw new Error('Failed to update playlist'); + } -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; + return null; + }, }; -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, -}; +// 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, +// }; +// }; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index bea42d98..b0b36f45 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -3,685 +3,610 @@ 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, SongListSort, + 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'; +import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; -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 VERSION_INFO: VersionInfo = [ + ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }], + ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }], +]; -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), - }; -}; +export const NavidromeController: ControllerEndpoint = { + addToPlaylist: async (args) => { + const { body, query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).addToPlaylist({ + body: { + ids: body.songId, + }, + params: { + id: query.id, + }, + }); -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), - }; -}; + if (res.status !== 200) { + throw new Error('Failed to add to playlist'); + } -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, - ); -}; + return null; + }, + authenticate: async (url, body): Promise => { + const cleanServerUrl = url.replace(/\/$/, ''); -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 res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({ + body: { + password: body.password, + username: body.username, + }, + }); -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, - ); -}; + if (res.status !== 200) { + throw new Error('Failed to authenticate'); + } -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), - }; -}; + 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, + }, + }); -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'); - } - - if ( - (query.sortBy === SongListSort.ALBUM || query.sortBy === SongListSort.ALBUM_ARTIST) && - !query.limit - ) { - const isAlbumArtist = query.sortBy === SongListSort.ALBUM_ARTIST; - - res.body.data.sort((a, b) => { - if (isAlbumArtist) { - const albumDiff = a.album.localeCompare(b.album); - if (albumDiff !== 0) { - return albumDiff; - } - } + if (res.status !== 200) { + throw new Error('Failed to create playlist'); + } - const discDiff = a.discNumber - b.discNumber; - if (discDiff !== 0) { - return discDiff; - } + 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, + }, + }); - const trackDiff = a.trackNumber - b.trackNumber; - if (trackDiff !== 0) { - return trackDiff; - } + if (res.status !== 200) { + throw new Error('Failed to delete playlist'); + } - return a.title.localeCompare(b.title); - }); - } - - 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), - }; -}; + return null; + }, + getAlbumArtistDetail: async (args) => { + const { query, apiClientProps } = args; -const getSongDetail = async (args: SongDetailArgs): Promise => { - const { query, apiClientProps } = args; + const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({ + params: { + id: query.id, + }, + }); - const res = await ndApiClient(apiClientProps).getSongDetail({ - 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 song detail'); - } + if (res.status !== 200) { + throw new Error('Failed to get album artist detail'); + } - return ndNormalize.song(res.body.data, apiClientProps.server, ''); -}; + if (!apiClientProps.server) { + throw new Error('Server is required'); + } -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, - }; -}; + // 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, + }, + }); -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; -}; + if (res.status !== 200) { + throw new Error('Failed to get album artist list'); + } -const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { - const { query, apiClientProps } = args; + 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), + }; + }, + getAlbumDetail: async (args) => { + const { query, apiClientProps } = args; + + const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({ + params: { + id: query.id, + }, + }); - const res = await ndApiClient(apiClientProps).deletePlaylist({ - body: null, - 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 (res.status !== 200) { - throw new Error('Failed to delete playlist'); - } + if (albumRes.status !== 200 || songsData.status !== 200) { + throw new Error('Failed to get album detail'); + } - return null; -}; + 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], + name: query.searchTerm, + ...query._custom?.navidrome, + }, + }); -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), - }; -}; + if (res.status !== 200) { + throw new Error('Failed to get album list'); + } -const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise => { - const { query, apiClientProps } = args; + 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), + }; + }, + 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, + }, + }); - const res = await ndApiClient(apiClientProps).getPlaylistDetail({ - params: { - id: query.id, - }, - }); + if (res.status !== 200) { + throw new Error('Failed to get genre list'); + } - if (res.status !== 200) { - throw new Error('Failed to get playlist detail'); - } + 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, + }, + }); - return ndNormalize.playlist(res.body.data, apiClientProps.server); -}; + if (res.status !== 200) { + throw new Error('Failed to get playlist detail'); + } -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), - }; -}; + 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 addToPlaylist = async (args: AddToPlaylistArgs): Promise => { - const { body, query, apiClientProps } = args; + 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, + }, + }); - const res = await ndApiClient(apiClientProps).addToPlaylist({ - body: { - ids: body.songId, - }, - params: { - id: query.id, - }, - }); + if (res.status !== 200) { + throw new Error('Failed to get playlist list'); + } - if (res.status !== 200) { - throw new Error('Failed to add to playlist'); - } + 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), + }; + }, + 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, + }, + }); - return null; -}; + if (res.status !== 200) { + throw new Error('Failed to get playlist song list'); + } -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; -}; + 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 VERSION_INFO: VersionInfo = [ - ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }], - ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }], -]; + const navidromeFeatures: Record = getFeatures( + VERSION_INFO, + ping.body.serverVersion!, + ); -const getServerInfo = async (args: ServerInfoArgs): Promise => { - const { apiClientProps } = args; + if (ping.body.openSubsonic) { + const res = await ssApiClient(apiClientProps).getServerInfo(); - // Navidrome will always populate serverVersion - const ping = await ssApiClient(apiClientProps).ping(); + if (res.status !== 200) { + throw new Error('Failed to get server extensions'); + } - if (ping.status !== 200) { - throw new Error('Failed to ping server'); - } + // 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 navidromeFeatures: Record = getFeatures( - VERSION_INFO, - ping.body.serverVersion!, - ); + 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! }; + }, + getSimilarSongs: async (args) => { + const { apiClientProps, query } = 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, + }, + }); - if (ping.body.openSubsonic) { - const res = await ssApiClient(apiClientProps).getServerInfo(); + 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 get server extensions'); - } + return acc; + }, []); - // 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; + if (similar.length > 0) { + return similar; } } - } - 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! }; -}; + const fallback = await ndApiClient(apiClientProps).getSongList({ + query: { + _end: 50, + _order: 'ASC', + _sort: NDSongListSort.RANDOM, + _start: 0, + album_artist_id: query.albumArtistIds, + }, + }); -const shareItem = async (args: ShareItemArgs): Promise => { - 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, - }; -}; + if (fallback.status !== 200) { + throw new Error('Failed to get similar songs'); + } -const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { - const { apiClientProps, query } = 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, - }, - }); - - 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; + + const res = await ndApiClient(apiClientProps).getSongDetail({ + params: { + id: query.id, + }, + }); - if (similar.length > 0) { - return similar; + if (res.status !== 200) { + throw new Error('Failed to get song detail'); } - } - - 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, '')); + + 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, + title: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list'); } - return acc; - }, []); -}; + if ( + (query.sortBy === SongListSort.ALBUM || query.sortBy === SongListSort.ALBUM_ARTIST) && + !query.limit + ) { + const isAlbumArtist = query.sortBy === SongListSort.ALBUM_ARTIST; + + res.body.data.sort((a, b) => { + if (isAlbumArtist) { + const albumDiff = a.album.localeCompare(b.album); + if (albumDiff !== 0) { + return albumDiff; + } + } -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'); - } -}; + const discDiff = a.discNumber - b.discNumber; + if (discDiff !== 0) { + return discDiff; + } + + const trackDiff = a.trackNumber - b.trackNumber; + if (trackDiff !== 0) { + return trackDiff; + } + + return a.title.localeCompare(b.title); + }); + } + + 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), + }; + }, + 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, + 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'); + } -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 null; + }, }; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index b575a8dc..2851b700 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1,103 +1,374 @@ 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 { - 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, -} from '/@/renderer/api/types'; +import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types'; +import { LibraryItem, Song, ControllerEndpoint } 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; +export const SubsonicController: Omit< + ControllerEndpoint, + | 'addToPlaylist' + | 'createPlaylist' + | 'deletePlaylist' + | 'getAlbumArtistDetail' + | 'getAlbumArtistList' + | 'getAlbumDetail' + | 'getAlbumList' + | 'getGenreList' + | 'getPlaylistDetail' + | 'getPlaylistList' + | 'getPlaylistSongList' + | 'getSongDetail' + | 'getSongList' + | 'movePlaylistItem' + | 'removeFromPlaylist' + | 'updatePlaylist' +> = { + authenticate: async (url, body) => { + 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, + }; + }, + 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; + }, + 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; }, -): 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, + getArtistInfo: async (args) => { + 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; + }, + 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' + ); + }, + 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, }; - } 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, + }, + 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, }; - } - - await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({ - query: { - c: 'Feishin', - f: 'json', - v: '1.13.0', - ...credentialParams, - }, - }); - - return { - credential, - userId: null, - username: body.username, - }; -}; + }, + 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; + } -const getMusicFolderList = async (args: MusicFolderListArgs): Promise => { - const { apiClientProps } = args; + return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; + }, + getSimilarSongs: async (args) => { + const { apiClientProps, query } = args; - const res = await ssApiClient(apiClientProps).getMusicFolderList({}); + const res = await ssApiClient(apiClientProps).getSimilarSongs({ + query: { + count: query.count, + id: query.songId, + }, + }); - if (res.status !== 200) { - throw new Error('Failed to get music folder list'); - } + 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; + }, []); + }, + getStructuredLyrics: async (args) => { + const { query, apiClientProps } = args; - return { - items: res.body.musicFolders.musicFolder, - startIndex: 0, - totalRecordCount: res.body.musicFolders.musicFolder.length, - }; + 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; + }, + 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; + }, }; // export const getAlbumArtistDetail = async ( @@ -205,324 +476,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/types.ts b/src/renderer/api/types.ts index b6bb79cf..eee4b965 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1228,3 +1228,44 @@ 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; + getAlbumDetail: (args: AlbumDetailArgs) => Promise; + getAlbumList: (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; + 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; +}; From fb6ff3860a41bd1abb469da628b2eb2da7bc97c8 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 22 Sep 2024 16:45:25 -0700 Subject: [PATCH 2/8] prepare subsonic --- src/renderer/api/controller.ts | 507 +++++------------- .../api/jellyfin/jellyfin-controller.ts | 41 +- .../api/navidrome/navidrome-controller.ts | 27 +- src/renderer/api/navidrome/navidrome-types.ts | 2 +- src/renderer/api/query-keys.ts | 46 ++ .../api/subsonic/subsonic-controller.ts | 36 +- src/renderer/api/types.ts | 76 ++- src/renderer/components/card/card-rows.tsx | 2 +- .../virtual-table/hooks/use-virtual-table.ts | 36 +- .../components/album-detail-content.tsx | 26 +- .../components/album-list-grid-view.tsx | 4 +- .../components/album-list-header-filters.tsx | 123 ++++- .../albums/components/album-list-header.tsx | 6 +- .../components/jellyfin-album-filters.tsx | 73 +-- .../components/navidrome-album-filters.tsx | 41 +- .../albums/queries/album-list-count-query.ts | 30 ++ .../albums/routes/album-list-route.tsx | 26 +- .../album-artist-detail-content.tsx | 27 +- .../components/album-artist-detail-header.tsx | 5 +- .../album-artist-list-grid-view.tsx | 6 +- .../album-artist-list-header-filters.tsx | 34 +- .../components/album-artist-list-header.tsx | 5 +- .../queries/album-artist-list-count-query.ts | 30 ++ .../artists/queries/top-songs-list-query.ts | 2 +- .../routes/album-artist-list-route.tsx | 19 +- .../components/genre-list-grid-view.tsx | 2 +- .../components/genre-list-header-filters.tsx | 25 +- .../genres/components/genre-list-header.tsx | 5 +- .../genres/routes/genre-list-route.tsx | 5 +- src/renderer/features/player/utils.ts | 18 +- .../components/playlist-detail-content.tsx | 252 --------- .../components/playlist-detail-header.tsx | 79 --- .../components/playlist-list-grid-view.tsx | 17 +- .../playlist-list-header-filters.tsx | 2 +- .../components/playlist-list-header.tsx | 4 +- .../components/playlist-list-table-view.tsx | 9 +- .../routes/playlist-detail-route.tsx | 77 --- .../playlists/routes/playlist-list-route.tsx | 4 +- .../search/components/search-header.tsx | 11 +- .../servers/components/add-server-form.tsx | 2 +- .../components/general/control-settings.tsx | 22 - .../shared/mutations/set-rating-mutation.ts | 2 +- .../components/sidebar-playlist-list.tsx | 42 +- .../features/sidebar/components/sidebar.tsx | 18 +- .../components/jellyfin-song-filters.tsx | 22 +- .../components/navidrome-song-filters.tsx | 26 +- .../songs/components/song-list-grid-view.tsx | 4 +- .../components/song-list-header-filters.tsx | 94 +++- .../songs/components/song-list-header.tsx | 18 +- .../songs/queries/song-list-count-query.ts | 30 ++ .../features/songs/routes/song-list-route.tsx | 24 +- src/renderer/hooks/use-display-refresh.ts | 7 +- src/renderer/hooks/use-list-filter-refresh.ts | 32 +- src/renderer/router/app-router.tsx | 9 - src/renderer/router/routes.ts | 1 - src/renderer/store/list.store.ts | 7 +- src/renderer/store/settings.store.ts | 2 - 57 files changed, 848 insertions(+), 1254 deletions(-) create mode 100644 src/renderer/features/albums/queries/album-list-count-query.ts create mode 100644 src/renderer/features/artists/queries/album-artist-list-count-query.ts delete mode 100644 src/renderer/features/playlists/components/playlist-detail-content.tsx delete mode 100644 src/renderer/features/playlists/components/playlist-detail-header.tsx delete mode 100644 src/renderer/features/playlists/routes/playlist-detail-route.tsx create mode 100644 src/renderer/features/songs/queries/song-list-count-query.ts diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 1b4f556c..1dc92383 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -1,41 +1,6 @@ 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, - RemoveFromPlaylistArgs, - ScrobbleArgs, - SearchArgs, - LyricsArgs, - ServerInfoArgs, - StructuredLyricsArgs, - SimilarSongsArgs, - ServerType, - MoveItemArgs, - DownloadArgs, - TranscodingArgs, - ControllerEndpoint, -} from '/@/renderer/api/types'; -import { RandomSongListArgs } from './types'; +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'; @@ -50,10 +15,13 @@ type ApiController = { const endpoints: ApiController = { jellyfin: JellyfinController, navidrome: NavidromeController, - subsonic: SubsonicController as ControllerEndpoint, + 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) { @@ -83,344 +51,127 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => ); } - return endpoints[serverType][endpoint]; -}; - -const authenticate = async ( - url: string, - body: { legacy?: boolean; password: string; username: string }, - type: ServerType, -) => { - return (apiController('authenticate', type) as ControllerEndpoint['authenticate'])?.(url, body); -}; - -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, + return controllerFn; +}; + +export interface GeneralController extends Omit, 'authenticate'> { + authenticate: ( + url: string, + body: { legacy?: boolean; password: string; username: string }, + type: ServerType, + ) => Promise; +} + +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 0243490b..cd17abee 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -229,6 +229,11 @@ export const JellyfinController: ControllerEndpoint = { 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; @@ -275,12 +280,8 @@ export const JellyfinController: ControllerEndpoint = { } 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 - ) { + if (query.minYear && query.maxYear) { + for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) { yearsGroup.push(String(i)); } } @@ -295,7 +296,10 @@ export const JellyfinController: ControllerEndpoint = { 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, @@ -318,6 +322,11 @@ export const JellyfinController: ControllerEndpoint = { 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; @@ -460,6 +469,11 @@ export const JellyfinController: ControllerEndpoint = { 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; @@ -636,12 +650,8 @@ export const JellyfinController: ControllerEndpoint = { } 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 - ) { + if (query.minYear && query.maxYear) { + for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) { yearsGroup.push(String(i)); } } @@ -662,7 +672,9 @@ export const JellyfinController: ControllerEndpoint = { 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, @@ -705,6 +717,11 @@ export const JellyfinController: ControllerEndpoint = { 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; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index b0b36f45..2035870f 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -184,6 +184,11 @@ export const NavidromeController: ControllerEndpoint = { 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; @@ -222,8 +227,11 @@ export const NavidromeController: ControllerEndpoint = { _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, }, }); @@ -237,6 +245,11 @@ export const NavidromeController: ControllerEndpoint = { 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; @@ -312,6 +325,11 @@ export const NavidromeController: ControllerEndpoint = { 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; @@ -320,7 +338,6 @@ export const NavidromeController: ControllerEndpoint = { 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] @@ -458,6 +475,8 @@ export const NavidromeController: ControllerEndpoint = { _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, }, @@ -503,6 +522,11 @@ export const NavidromeController: ControllerEndpoint = { 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) => { @@ -566,6 +590,7 @@ export const NavidromeController: ControllerEndpoint = { }, scrobble: SubsonicController.scrobble, search: SubsonicController.search, + setRating: SubsonicController.setRating, shareItem: async (args) => { const { body, apiClientProps } = args; 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-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 2851b700..9c400e81 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -11,16 +11,20 @@ export const SubsonicController: Omit< | 'addToPlaylist' | 'createPlaylist' | 'deletePlaylist' + | 'getAlbumArtistListCount' | 'getAlbumArtistDetail' | 'getAlbumArtistList' | 'getAlbumDetail' | 'getAlbumList' + | 'getAlbumListCount' | 'getGenreList' | 'getPlaylistDetail' | 'getPlaylistList' + | 'getPlaylistListCount' | 'getPlaylistSongList' | 'getSongDetail' | 'getSongList' + | 'getSongListCount' | 'movePlaylistItem' | 'removeFromPlaylist' | 'updatePlaylist' @@ -102,22 +106,22 @@ export const SubsonicController: Omit< return null; }, - getArtistInfo: async (args) => { - 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; - }, + // getArtistInfo: async (args) => { + // 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; + // }, getDownloadUrl: (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index eee4b965..1227b268 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -128,7 +128,7 @@ export interface BasePaginatedResponse { error?: string | any; items: T; startIndex: number; - totalRecordCount: number; + totalRecordCount: number | null; } export type AuthenticationResponse = { @@ -309,6 +309,11 @@ type BaseEndpointArgs = { }; }; +export interface BaseQuery { + sortBy: T; + sortOrder: SortOrder; +} + // Genre List export type GenreListResponse = BasePaginatedResponse | null | undefined; @@ -318,7 +323,7 @@ export enum GenreListSort { NAME = 'name', } -export type GenreListQuery = { +export interface GenreListQuery extends BaseQuery { _custom?: { jellyfin?: null; navidrome?: null; @@ -326,10 +331,8 @@ export type GenreListQuery = { limit?: number; musicFolderId?: string; searchTerm?: string; - sortBy: GenreListSort; - sortOrder: SortOrder; startIndex: number; -}; +} type GenreListSortMap = { jellyfin: Record; @@ -370,22 +373,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 +484,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 +597,7 @@ export enum AlbumArtistListSort { SONG_COUNT = 'songCount', } -export type AlbumArtistListQuery = { +export interface AlbumArtistListQuery extends BaseQuery { _custom?: { jellyfin?: Partial>; navidrome?: Partial>; @@ -603,10 +605,8 @@ export type AlbumArtistListQuery = { limit?: number; musicFolderId?: string; searchTerm?: string; - sortBy: AlbumArtistListSort; - sortOrder: SortOrder; startIndex: number; -}; +} export type AlbumArtistListArgs = { query: AlbumArtistListQuery } & BaseEndpointArgs; @@ -683,17 +683,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 +877,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 +959,7 @@ export enum UserListSort { NAME = 'name', } -export type UserListQuery = { +export interface UserListQuery extends BaseQuery { _custom?: { navidrome?: { owner_id?: string; @@ -971,10 +967,8 @@ export type UserListQuery = { }; limit?: number; searchTerm?: string; - sortBy: UserListSort; - sortOrder: SortOrder; startIndex: number; -}; +} export type UserListArgs = { query: UserListQuery } & BaseEndpointArgs; @@ -1241,22 +1235,26 @@ export type ControllerEndpoint = { deletePlaylist: (args: DeletePlaylistArgs) => Promise; getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise; getAlbumArtistList: (args: AlbumArtistListArgs) => Promise; + getAlbumArtistListCount: (args: AlbumArtistListArgs) => Promise; getAlbumDetail: (args: AlbumDetailArgs) => Promise; getAlbumList: (args: AlbumListArgs) => Promise; - getArtistInfo?: (args: any) => void; - getArtistList?: (args: ArtistListArgs) => 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; 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..63acc33d 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 + : (properties.filter.offset || 0) + results.items.length; + + params.successCallback( + results?.items || [], + hasMoreRows ? undefined : lastRowIndex, + ); + return; + } + params.successCallback(results?.items || [], results?.totalRecordCount || 0); }, rowCount: undefined, @@ -335,10 +356,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 +392,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/queries/album-list-count-query.ts b/src/renderer/features/albums/queries/album-list-count-query.ts new file mode 100644 index 00000000..e4c13042 --- /dev/null +++ b/src/renderer/features/albums/queries/album-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 { AlbumListQuery } from '/@/renderer/api/types'; +import type { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { getServerById } from '/@/renderer/store'; + +export const useAlbumListCount = (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.getAlbumListCount({ + apiClientProps: { + server, + signal, + }, + query, + }); + }, + queryKey: queryKeys.albums.count( + serverId || '', + Object.keys(query).length === 0 ? undefined : query, + ), + ...options, + }); +}; diff --git a/src/renderer/features/albums/routes/album-list-route.tsx b/src/renderer/features/albums/routes/album-list-route.tsx index 8c791ed7..59c397d6 100644 --- a/src/renderer/features/albums/routes/album-list-route.tsx +++ b/src/renderer/features/albums/routes/album-list-route.tsx @@ -5,12 +5,11 @@ import { useTranslation } from 'react-i18next'; import { useParams, useSearchParams } from 'react-router-dom'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { AlbumListQuery, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { ListContext } from '/@/renderer/context/list-context'; import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content'; import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header'; -import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { AnimatedPage } from '/@/renderer/features/shared'; import { queryClient } from '/@/renderer/lib/react-query'; @@ -18,6 +17,7 @@ import { useCurrentServer, useListFilterByKey } from '/@/renderer/store'; import { Play } from '/@/renderer/types'; import { useGenreList } from '/@/renderer/features/genres'; import { titleCase } from '/@/renderer/utils'; +import { useAlbumListCount } from '/@/renderer/features/albums/queries/album-list-count-query'; const AlbumListRoute = () => { const { t } = useTranslation(); @@ -33,14 +33,7 @@ const AlbumListRoute = () => { const value = { ...(albumArtistId && { artistIds: [albumArtistId] }), ...(genreId && { - _custom: { - jellyfin: { - GenreIds: genreId, - }, - navidrome: { - genre_id: genreId, - }, - }, + genres: [genreId], }), }; @@ -51,7 +44,7 @@ const AlbumListRoute = () => { return value; }, [albumArtistId, genreId]); - const albumListFilter = useListFilterByKey({ + const albumListFilter = useListFilterByKey({ filter: customFilters, key: pageKey, }); @@ -78,32 +71,27 @@ const AlbumListRoute = () => { return genre?.name; }, [genreId, genreList.data]); - const itemCountCheck = useAlbumList({ + const itemCountCheck = useAlbumListCount({ options: { cacheTime: 1000 * 60, staleTime: 1000 * 60, }, query: { - limit: 1, - startIndex: 0, ...albumListFilter, }, 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 { playType } = args; const query = { - startIndex: 0, ...albumListFilter, ...customFilters, + startIndex: 0, }; const queryKey = queryKeys.albums.list(server?.id || '', query); diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx index 34e77b99..eaf7468b 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -111,18 +111,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten enabled: enabledItem.recentAlbums, }, query: { - _custom: { - jellyfin: { - ...(server?.type === ServerType.JELLYFIN - ? { AlbumArtistIds: albumArtistId } - : undefined), - }, - navidrome: { - ...(server?.type === ServerType.NAVIDROME - ? { artist_id: albumArtistId, compilation: false } - : undefined), - }, - }, + artistIds: [albumArtistId], limit: 15, sortBy: AlbumListSort.RELEASE_DATE, sortOrder: SortOrder.DESC, @@ -136,18 +125,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten enabled: enabledItem.compilations, }, query: { - _custom: { - jellyfin: { - ...(server?.type === ServerType.JELLYFIN - ? { ContributingArtistIds: albumArtistId } - : undefined), - }, - navidrome: { - ...(server?.type === ServerType.NAVIDROME - ? { artist_id: albumArtistId, compilation: true } - : undefined), - }, - }, + artistIds: [albumArtistId], + compilation: true, limit: 15, sortBy: AlbumListSort.RELEASE_DATE, sortOrder: SortOrder.DESC, diff --git a/src/renderer/features/artists/components/album-artist-detail-header.tsx b/src/renderer/features/artists/components/album-artist-detail-header.tsx index e36eac40..81af884c 100644 --- a/src/renderer/features/artists/components/album-artist-detail-header.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-header.tsx @@ -26,16 +26,19 @@ export const AlbumArtistDetailHeader = forwardRef( const metadataItems = [ { + enabled: detailQuery?.data?.albumCount, id: 'albumCount', secondary: false, value: t('entity.albumWithCount', { count: detailQuery?.data?.albumCount || 0 }), }, { + enabled: detailQuery?.data?.songCount, id: 'songCount', secondary: false, value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }), }, { + enabled: detailQuery.data?.duration, id: 'duration', secondary: true, value: @@ -70,7 +73,7 @@ export const AlbumArtistDetailHeader = forwardRef( {metadataItems - .filter((i) => i.value) + .filter((i) => i.enabled) .map((item, index) => ( {index > 0 && •} diff --git a/src/renderer/features/artists/components/album-artist-list-grid-view.tsx b/src/renderer/features/artists/components/album-artist-list-grid-view.tsx index 9ceabbb2..3e6dd746 100644 --- a/src/renderer/features/artists/components/album-artist-list-grid-view.tsx +++ b/src/renderer/features/artists/components/album-artist-list-grid-view.tsx @@ -11,7 +11,6 @@ import { AlbumArtistListQuery, AlbumArtistListResponse, AlbumArtistListSort, - ArtistListQuery, LibraryItem, } from '/@/renderer/api/types'; import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components'; @@ -34,7 +33,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG const handlePlayQueueAdd = usePlayQueueAdd(); const { pageKey } = useListContext(); - const { grid, display, filter } = useListStoreByKey({ key: pageKey }); + const { grid, display, filter } = useListStoreByKey({ key: pageKey }); const { setGrid } = useListStoreActions(); const handleFavorite = useHandleFavorite({ gridRef, server }); @@ -73,7 +72,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG const fetch = useCallback( async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => { - const query: ArtistListQuery = { + const query: AlbumArtistListQuery = { ...filter, limit, startIndex, @@ -91,7 +90,6 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG }, query: { limit, - startIndex, ...filter, }, }), diff --git a/src/renderer/features/artists/components/album-artist-list-header-filters.tsx b/src/renderer/features/artists/components/album-artist-list-header-filters.tsx index ea4e440c..dc1a4c53 100644 --- a/src/renderer/features/artists/components/album-artist-list-header-filters.tsx +++ b/src/renderer/features/artists/components/album-artist-list-header-filters.tsx @@ -9,7 +9,13 @@ import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react import { useListContext } from '../../../context/list-context'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { AlbumArtistListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types'; +import { + AlbumArtistListQuery, + AlbumArtistListSort, + 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 { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; @@ -85,6 +91,28 @@ const FILTERS = { value: AlbumArtistListSort.SONG_COUNT, }, ], + subsonic: [ + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }), + value: AlbumArtistListSort.ALBUM_COUNT, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }), + value: AlbumArtistListSort.FAVORITED, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: AlbumArtistListSort.NAME, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.rating', { postProcess: 'titleCase' }), + value: AlbumArtistListSort.RATING, + }, + ], }; interface AlbumArtistListHeaderFiltersProps { @@ -100,7 +128,9 @@ export const AlbumArtistListHeaderFilters = ({ const queryClient = useQueryClient(); const server = useCurrentServer(); const { pageKey } = useListContext(); - const { display, table, grid, filter } = useListStoreByKey({ key: pageKey }); + const { display, table, grid, filter } = useListStoreByKey({ + key: pageKey, + }); const { setFilter, setTable, setTablePagination, setDisplayType, setGrid } = useListStoreActions(); const cq = useContainerQuery(); diff --git a/src/renderer/features/artists/components/album-artist-list-header.tsx b/src/renderer/features/artists/components/album-artist-list-header.tsx index 2cdac138..6ce0cfd4 100644 --- a/src/renderer/features/artists/components/album-artist-list-header.tsx +++ b/src/renderer/features/artists/components/album-artist-list-header.tsx @@ -4,7 +4,7 @@ import { Flex, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { useTranslation } from 'react-i18next'; import { FilterBar } from '../../shared/components/filter-bar'; -import { LibraryItem } from '/@/renderer/api/types'; +import { AlbumArtistListQuery, LibraryItem } from '/@/renderer/api/types'; import { PageHeader, SearchInput } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters'; @@ -28,8 +28,9 @@ export const AlbumArtistListHeader = ({ const server = useCurrentServer(); const cq = useContainerQuery(); - const { filter, refresh, search } = useDisplayRefresh({ + const { filter, refresh, search } = useDisplayRefresh({ gridRef, + itemCount, itemType: LibraryItem.ALBUM_ARTIST, server, tableRef, diff --git a/src/renderer/features/artists/queries/album-artist-list-count-query.ts b/src/renderer/features/artists/queries/album-artist-list-count-query.ts new file mode 100644 index 00000000..f96dceaa --- /dev/null +++ b/src/renderer/features/artists/queries/album-artist-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 { AlbumArtistListQuery } from '/@/renderer/api/types'; +import { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { getServerById } from '/@/renderer/store'; + +export const useAlbumArtistListCount = (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.getAlbumArtistListCount({ + apiClientProps: { + server, + signal, + }, + query, + }); + }, + queryKey: queryKeys.albumArtists.count( + serverId || '', + Object.keys(query).length === 0 ? undefined : query, + ), + ...options, + }); +}; diff --git a/src/renderer/features/artists/queries/top-songs-list-query.ts b/src/renderer/features/artists/queries/top-songs-list-query.ts index adfe44f4..41430210 100644 --- a/src/renderer/features/artists/queries/top-songs-list-query.ts +++ b/src/renderer/features/artists/queries/top-songs-list-query.ts @@ -13,7 +13,7 @@ export const useTopSongsList = (args: QueryHookArgs) => { enabled: !!server?.id, queryFn: ({ signal }) => { if (!server) throw new Error('Server not found'); - return api.controller.getTopSongList({ apiClientProps: { server, signal }, query }); + return api.controller.getTopSongs({ apiClientProps: { server, signal }, query }); }, queryKey: queryKeys.albumArtists.topSongs(server?.id || '', query), ...options, diff --git a/src/renderer/features/artists/routes/album-artist-list-route.tsx b/src/renderer/features/artists/routes/album-artist-list-route.tsx index 0f68bd57..ff4dba84 100644 --- a/src/renderer/features/artists/routes/album-artist-list-route.tsx +++ b/src/renderer/features/artists/routes/album-artist-list-route.tsx @@ -2,13 +2,13 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { useMemo, useRef } from 'react'; import { useCurrentServer } from '../../../store/auth.store'; import { useListFilterByKey } from '../../../store/list.store'; -import { LibraryItem } from '/@/renderer/api/types'; +import { AlbumArtistListQuery, LibraryItem } from '/@/renderer/api/types'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { ListContext } from '/@/renderer/context/list-context'; import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content'; import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header'; -import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; import { AnimatedPage } from '/@/renderer/features/shared'; +import { useAlbumArtistListCount } from '/@/renderer/features/artists/queries/album-artist-list-count-query'; const AlbumArtistListRoute = () => { const gridRef = useRef(null); @@ -16,25 +16,18 @@ const AlbumArtistListRoute = () => { const pageKey = LibraryItem.ALBUM_ARTIST; const server = useCurrentServer(); - const albumArtistListFilter = useListFilterByKey({ key: pageKey }); + const albumArtistListFilter = useListFilterByKey({ key: pageKey }); - const itemCountCheck = useAlbumArtistList({ + const itemCountCheck = useAlbumArtistListCount({ options: { cacheTime: 1000 * 60, staleTime: 1000 * 60, }, - query: { - limit: 1, - startIndex: 0, - ...albumArtistListFilter, - }, + query: albumArtistListFilter, serverId: server?.id, }); - const itemCount = - itemCountCheck.data?.totalRecordCount === null - ? undefined - : itemCountCheck.data?.totalRecordCount; + const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data; const providerValue = useMemo(() => { return { diff --git a/src/renderer/features/genres/components/genre-list-grid-view.tsx b/src/renderer/features/genres/components/genre-list-grid-view.tsx index 37f92c49..a3bd5d31 100644 --- a/src/renderer/features/genres/components/genre-list-grid-view.tsx +++ b/src/renderer/features/genres/components/genre-list-grid-view.tsx @@ -22,7 +22,7 @@ export const GenreListGridView = ({ gridRef, itemCount }: any) => { const server = useCurrentServer(); const handlePlayQueueAdd = usePlayQueueAdd(); const { pageKey, id } = useListContext(); - const { grid, display, filter } = useListStoreByKey({ key: pageKey }); + const { grid, display, filter } = useListStoreByKey({ key: pageKey }); const { setGrid } = useListStoreActions(); const genrePath = useGenreRoute(); diff --git a/src/renderer/features/genres/components/genre-list-header-filters.tsx b/src/renderer/features/genres/components/genre-list-header-filters.tsx index e57b43a4..40a7db15 100644 --- a/src/renderer/features/genres/components/genre-list-header-filters.tsx +++ b/src/renderer/features/genres/components/genre-list-header-filters.tsx @@ -12,7 +12,13 @@ import { RiSettings3Fill, } from 'react-icons/ri'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { GenreListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types'; +import { + GenreListQuery, + GenreListSort, + 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 { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; @@ -47,25 +53,38 @@ const FILTERS = { value: GenreListSort.NAME, }, ], + subsonic: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: GenreListSort.NAME, + }, + ], }; interface GenreListHeaderFiltersProps { gridRef: MutableRefObject; + itemCount: number | undefined; tableRef: MutableRefObject; } -export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => { +export const GenreListHeaderFilters = ({ + gridRef, + itemCount, + tableRef, +}: GenreListHeaderFiltersProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); const { pageKey, customFilters } = useListContext(); const server = useCurrentServer(); const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions(); - const { display, filter, table, grid } = useListStoreByKey({ key: pageKey }); + const { display, filter, table, grid } = useListStoreByKey({ key: pageKey }); const cq = useContainerQuery(); const { genreTarget } = useGeneralSettings(); const { setGenreBehavior } = useSettingsStoreActions(); const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.GENRE, server, }); diff --git a/src/renderer/features/genres/components/genre-list-header.tsx b/src/renderer/features/genres/components/genre-list-header.tsx index 100870f6..c24ad224 100644 --- a/src/renderer/features/genres/components/genre-list-header.tsx +++ b/src/renderer/features/genres/components/genre-list-header.tsx @@ -2,7 +2,7 @@ import { ChangeEvent, MutableRefObject } from 'react'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { Flex, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; -import { LibraryItem } from '/@/renderer/api/types'; +import { GenreListQuery, LibraryItem } from '/@/renderer/api/types'; import { PageHeader, SearchInput } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters'; @@ -22,7 +22,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade const { t } = useTranslation(); const cq = useContainerQuery(); const server = useCurrentServer(); - const { filter, refresh, search } = useDisplayRefresh({ + const { filter, refresh, search } = useDisplayRefresh({ gridRef, itemType: LibraryItem.GENRE, server, @@ -66,6 +66,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade diff --git a/src/renderer/features/genres/routes/genre-list-route.tsx b/src/renderer/features/genres/routes/genre-list-route.tsx index 72a7c375..2bff14cf 100644 --- a/src/renderer/features/genres/routes/genre-list-route.tsx +++ b/src/renderer/features/genres/routes/genre-list-route.tsx @@ -8,19 +8,20 @@ import { useGenreList } from '/@/renderer/features/genres/queries/genre-list-que import { AnimatedPage } from '/@/renderer/features/shared'; import { useCurrentServer } from '/@/renderer/store'; import { useListStoreByKey } from '../../../store/list.store'; +import { GenreListQuery } from '/@/renderer/api/types'; const GenreListRoute = () => { const gridRef = useRef(null); const tableRef = useRef(null); const server = useCurrentServer(); const pageKey = 'genre'; - const { filter } = useListStoreByKey({ key: pageKey }); + const { filter } = useListStoreByKey({ key: pageKey }); const itemCountCheck = useGenreList({ query: { + ...filter, limit: 1, startIndex: 0, - ...filter, }, serverId: server?.id, }); diff --git a/src/renderer/features/player/utils.ts b/src/renderer/features/player/utils.ts index 0b1a65fc..2e319d95 100644 --- a/src/renderer/features/player/utils.ts +++ b/src/renderer/features/player/utils.ts @@ -9,7 +9,6 @@ import { SongListSort, SortOrder, ServerListItem, - ServerType, } from '/@/renderer/api/types'; export const getPlaylistSongsById = async (args: { @@ -103,18 +102,7 @@ export const getGenreSongsById = async (args: { }; for (const genreId of id) { const queryFilter: SongListQuery = { - _custom: { - ...(server?.type === ServerType.JELLYFIN && { - jellyfin: { - GenreIds: genreId, - }, - }), - ...(server?.type === ServerType.NAVIDROME && { - navidrome: { - genre_id: genreId, - }, - }), - }, + genreIds: [genreId], sortBy: SongListSort.GENRE, sortOrder: SortOrder.ASC, startIndex: 0, @@ -140,7 +128,9 @@ export const getGenreSongsById = async (args: { ); data.items.push(...res!.items); - data.totalRecordCount += res!.totalRecordCount; + if (data.totalRecordCount) { + data.totalRecordCount += res!.totalRecordCount || 0; + } } return data; diff --git a/src/renderer/features/playlists/components/playlist-detail-content.tsx b/src/renderer/features/playlists/components/playlist-detail-content.tsx deleted file mode 100644 index 24d1a916..00000000 --- a/src/renderer/features/playlists/components/playlist-detail-content.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import { MutableRefObject, useMemo, useRef } from 'react'; -import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core'; -import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Box, Group } from '@mantine/core'; -import { closeAllModals, openModal } from '@mantine/modals'; -import { useTranslation } from 'react-i18next'; -import { RiMoreFill } from 'react-icons/ri'; -import { generatePath, useNavigate, useParams } from 'react-router'; -import { Link } from 'react-router-dom'; -import styled from 'styled-components'; -import { useListStoreByKey } from '../../../store/list.store'; -import { LibraryItem, QueueSong } from '/@/renderer/api/types'; -import { Button, ConfirmModal, DropdownMenu, MotionGroup, toast } from '/@/renderer/components'; -import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table'; -import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles'; -import { useHandleTableContextMenu } from '/@/renderer/features/context-menu'; -import { - PLAYLIST_SONG_CONTEXT_MENU_ITEMS, - SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS, -} from '/@/renderer/features/context-menu/context-menu-items'; -import { usePlayQueueAdd } from '/@/renderer/features/player'; -import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form'; -import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation'; -import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; -import { usePlaylistSongListInfinite } from '/@/renderer/features/playlists/queries/playlist-song-list-query'; -import { PlayButton, PLAY_TYPES } from '/@/renderer/features/shared'; -import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer } from '/@/renderer/store'; -import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; -import { Play } from '/@/renderer/types'; - -const ContentContainer = styled.div` - position: relative; - display: flex; - flex-direction: column; - padding: 1rem 2rem 5rem; - overflow: hidden; - - .ag-theme-alpine-dark { - --ag-header-background-color: rgb(0 0 0 / 0%) !important; - } -`; - -interface PlaylistDetailContentProps { - tableRef: MutableRefObject; -} - -export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const { playlistId } = useParams() as { playlistId: string }; - const { table } = useListStoreByKey({ key: LibraryItem.SONG }); - const handlePlayQueueAdd = usePlayQueueAdd(); - const server = useCurrentServer(); - const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id }); - const playButtonBehavior = usePlayButtonBehavior(); - - const playlistSongsQueryInfinite = usePlaylistSongListInfinite({ - options: { - cacheTime: 0, - keepPreviousData: false, - }, - query: { - id: playlistId, - limit: 50, - startIndex: 0, - }, - serverId: server?.id, - }); - - const handleLoadMore = () => { - playlistSongsQueryInfinite.fetchNextPage(); - }; - - const columnDefs: ColDef[] = useMemo( - () => - getColumnDefs(table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'), - [table.columns], - ); - - const contextMenuItems = useMemo(() => { - if (detailQuery?.data?.rules) { - return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS; - } - - return PLAYLIST_SONG_CONTEXT_MENU_ITEMS; - }, [detailQuery?.data?.rules]); - - const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, { - playlistId, - }); - - const playlistSongData = useMemo( - () => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p?.items), - [playlistSongsQueryInfinite.data?.pages], - ); - - const deletePlaylistMutation = useDeletePlaylist({}); - - const handleDeletePlaylist = () => { - deletePlaylistMutation.mutate( - { query: { id: playlistId }, serverId: server?.id }, - { - onError: (err) => { - toast.error({ - message: err.message, - title: t('error.genericError', { postProcess: 'sentenceCase' }), - }); - }, - onSuccess: () => { - closeAllModals(); - navigate(AppRoute.PLAYLISTS); - }, - }, - ); - }; - - const openDeletePlaylist = () => { - openModal({ - children: ( - - Are you sure you want to delete this playlist? - - ), - title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }), - }); - }; - - const handlePlay = (playType?: Play) => { - handlePlayQueueAdd?.({ - byItemType: { - id: [playlistId], - type: LibraryItem.PLAYLIST, - }, - playType: playType || playButtonBehavior, - }); - }; - - const handleRowDoubleClick = (e: RowDoubleClickedEvent) => { - if (!e.data) return; - - handlePlayQueueAdd?.({ - byItemType: { - id: [playlistId], - type: LibraryItem.PLAYLIST, - }, - initialSongId: e.data.id, - playType: playButtonBehavior, - }); - }; - - const { rowClassRules } = useCurrentSongRowStyles({ tableRef }); - - const loadMoreRef = useRef(null); - - return ( - - - - handlePlay()} /> - - - - - - {PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map( - (type) => ( - handlePlay(type.play)} - > - {type.label} - - ), - )} - - { - if (!detailQuery.data || !server) return; - openUpdatePlaylistModal({ playlist: detailQuery.data, server }); - }} - > - Edit playlist - - - Delete playlist - - - - - - - - `${data.data.uniqueId}-${data.data.pageIndex}`} - rowClassRules={rowClassRules} - rowData={playlistSongData} - rowHeight={60} - rowSelection="multiple" - onCellContextMenu={handleContextMenu} - onRowDoubleClicked={handleRowDoubleClick} - /> - - - - - - ); -}; diff --git a/src/renderer/features/playlists/components/playlist-detail-header.tsx b/src/renderer/features/playlists/components/playlist-detail-header.tsx deleted file mode 100644 index 1235681c..00000000 --- a/src/renderer/features/playlists/components/playlist-detail-header.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { forwardRef, Fragment, Ref } from 'react'; -import { Group, Stack } from '@mantine/core'; -import { useParams } from 'react-router'; -import { Badge, Text } from '/@/renderer/components'; -import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; -import { LibraryHeader } from '/@/renderer/features/shared'; -import { AppRoute } from '/@/renderer/router/routes'; -import { formatDurationString } from '/@/renderer/utils'; -import { LibraryItem } from '/@/renderer/api/types'; -import { useCurrentServer } from '../../../store/auth.store'; - -interface PlaylistDetailHeaderProps { - background: string; - imagePlaceholderUrl?: string | null; - imageUrl?: string | null; -} - -export const PlaylistDetailHeader = forwardRef( - ( - { background, imageUrl, imagePlaceholderUrl }: PlaylistDetailHeaderProps, - ref: Ref, - ) => { - const { playlistId } = useParams() as { playlistId: string }; - const server = useCurrentServer(); - const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id }); - - const metadataItems = [ - { - id: 'songCount', - secondary: false, - value: `${detailQuery?.data?.songCount || 0} songs`, - }, - { - id: 'duration', - secondary: true, - value: - detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration), - }, - ]; - - const isSmartPlaylist = detailQuery?.data?.rules; - - return ( - - - - - {metadataItems.map((item, index) => ( - - {index > 0 && •} - {item.value} - - ))} - {isSmartPlaylist && ( - <> - • - - Smart Playlist - - - )} - - {detailQuery?.data?.description} - - - - ); - }, -); diff --git a/src/renderer/features/playlists/components/playlist-list-grid-view.tsx b/src/renderer/features/playlists/components/playlist-list-grid-view.tsx index cd8ee5b1..9dad4a9e 100644 --- a/src/renderer/features/playlists/components/playlist-list-grid-view.tsx +++ b/src/renderer/features/playlists/components/playlist-list-grid-view.tsx @@ -21,7 +21,7 @@ import { } from '/@/renderer/components/virtual-grid'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer, useGeneralSettings, useListStoreByKey } from '/@/renderer/store'; +import { useCurrentServer, useListStoreByKey } from '/@/renderer/store'; import { CardRow, ListDisplayType } from '/@/renderer/types'; import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite'; @@ -35,15 +35,12 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie const queryClient = useQueryClient(); const server = useCurrentServer(); const handlePlayQueueAdd = usePlayQueueAdd(); - const { display, grid, filter } = useListStoreByKey({ key: pageKey }); + const { display, grid, filter } = useListStoreByKey({ key: pageKey }); const { setGrid } = useListStoreActions(); - const { defaultFullPlaylist } = useGeneralSettings(); const handleFavorite = useHandleFavorite({ gridRef, server }); const cardRows = useMemo(() => { - const rows: CardRow[] = defaultFullPlaylist - ? [PLAYLIST_CARD_ROWS.nameFull] - : [PLAYLIST_CARD_ROWS.name]; + const rows: CardRow[] = [PLAYLIST_CARD_ROWS.nameFull]; switch (filter.sortBy) { case PlaylistListSort.DURATION: @@ -66,7 +63,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie } return rows; - }, [defaultFullPlaylist, filter.sortBy]); + }, [filter.sortBy]); const handleGridScroll = useCallback( (e: ListOnScrollProps) => { @@ -116,9 +113,9 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie const query: PlaylistListQuery = { limit: take, - startIndex: skip, ...filter, _custom: {}, + startIndex: skip, }; const queryKey = queryKeys.playlists.list(server?.id || '', query); @@ -160,9 +157,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie loading={itemCount === undefined || itemCount === null} minimumBatchSize={40} route={{ - route: defaultFullPlaylist - ? AppRoute.PLAYLISTS_DETAIL_SONGS - : AppRoute.PLAYLISTS_DETAIL, + route: AppRoute.PLAYLISTS_DETAIL_SONGS, slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }], }} width={width} diff --git a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx index 211b92fd..470cd6c5 100644 --- a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx @@ -86,7 +86,7 @@ export const PlaylistListHeaderFilters = ({ const server = useCurrentServer(); const { setFilter, setTable, setTablePagination, setGrid, setDisplayType } = useListStoreActions(); - const { display, filter, table, grid } = useListStoreByKey({ key: pageKey }); + const { display, filter, table, grid } = useListStoreByKey({ key: pageKey }); const cq = useContainerQuery(); const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; diff --git a/src/renderer/features/playlists/components/playlist-list-header.tsx b/src/renderer/features/playlists/components/playlist-list-header.tsx index faf1974e..d8fcb46d 100644 --- a/src/renderer/features/playlists/components/playlist-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header.tsx @@ -12,7 +12,7 @@ import { PlaylistListFilter, useCurrentServer } from '/@/renderer/store'; import debounce from 'lodash/debounce'; import { useTranslation } from 'react-i18next'; import { RiFileAddFill } from 'react-icons/ri'; -import { LibraryItem, ServerType } from '/@/renderer/api/types'; +import { LibraryItem, PlaylistListQuery, ServerType } from '/@/renderer/api/types'; import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh'; interface PlaylistListHeaderProps { @@ -37,7 +37,7 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis }); }; - const { filter, refresh, search } = useDisplayRefresh({ + const { filter, refresh, search } = useDisplayRefresh({ gridRef, itemType: LibraryItem.PLAYLIST, server, diff --git a/src/renderer/features/playlists/components/playlist-list-table-view.tsx b/src/renderer/features/playlists/components/playlist-list-table-view.tsx index 6decfea2..af5fe0e3 100644 --- a/src/renderer/features/playlists/components/playlist-list-table-view.tsx +++ b/src/renderer/features/playlists/components/playlist-list-table-view.tsx @@ -8,7 +8,7 @@ import { VirtualTable } from '/@/renderer/components/virtual-table'; import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table'; import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer, useGeneralSettings } from '/@/renderer/store'; +import { useCurrentServer } from '/@/renderer/store'; interface PlaylistListTableViewProps { itemCount?: number; @@ -18,16 +18,11 @@ interface PlaylistListTableViewProps { export const PlaylistListTableView = ({ tableRef, itemCount }: PlaylistListTableViewProps) => { const navigate = useNavigate(); const server = useCurrentServer(); - const { defaultFullPlaylist } = useGeneralSettings(); const pageKey = 'playlist'; const handleRowDoubleClick = (e: RowDoubleClickedEvent) => { if (!e.data) return; - if (defaultFullPlaylist) { - navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id })); - } else { - navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id })); - } + navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id })); }; const tableProps = useVirtualTable({ diff --git a/src/renderer/features/playlists/routes/playlist-detail-route.tsx b/src/renderer/features/playlists/routes/playlist-detail-route.tsx deleted file mode 100644 index 92a7670b..00000000 --- a/src/renderer/features/playlists/routes/playlist-detail-route.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useRef } from 'react'; -import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { useParams } from 'react-router'; -import { LibraryItem } from '/@/renderer/api/types'; -import { NativeScrollArea, Spinner } from '/@/renderer/components'; -import { usePlayQueueAdd } from '/@/renderer/features/player'; -import { PlaylistDetailContent } from '/@/renderer/features/playlists/components/playlist-detail-content'; -import { PlaylistDetailHeader } from '/@/renderer/features/playlists/components/playlist-detail-header'; -import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; -import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared'; -import { useFastAverageColor } from '/@/renderer/hooks'; -import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; -import { useCurrentServer } from '../../../store/auth.store'; - -const PlaylistDetailRoute = () => { - const tableRef = useRef(null); - const scrollAreaRef = useRef(null); - const headerRef = useRef(null); - const { playlistId } = useParams() as { playlistId: string }; - const server = useCurrentServer(); - - const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id }); - const { color: background, colorId } = useFastAverageColor({ - algorithm: 'sqrt', - id: playlistId, - src: detailQuery?.data?.imageUrl, - srcLoaded: !detailQuery?.isLoading, - }); - - const handlePlayQueueAdd = usePlayQueueAdd(); - const playButtonBehavior = usePlayButtonBehavior(); - - const handlePlay = () => { - handlePlayQueueAdd?.({ - byItemType: { - id: [playlistId], - type: LibraryItem.PLAYLIST, - }, - playType: playButtonBehavior, - }); - }; - - if (!background || colorId !== playlistId) { - return ; - } - - return ( - - - - - {detailQuery?.data?.name} - - - ), - offset: 200, - target: headerRef, - }} - > - - - - - ); -}; - -export default PlaylistDetailRoute; diff --git a/src/renderer/features/playlists/routes/playlist-list-route.tsx b/src/renderer/features/playlists/routes/playlist-list-route.tsx index 86f9fc5f..ef2abc79 100644 --- a/src/renderer/features/playlists/routes/playlist-list-route.tsx +++ b/src/renderer/features/playlists/routes/playlist-list-route.tsx @@ -1,7 +1,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { useMemo, useRef } from 'react'; import { useParams } from 'react-router'; -import { PlaylistListSort, SortOrder } from '/@/renderer/api/types'; +import { PlaylistListSort, PlaylistSongListQuery, SortOrder } from '/@/renderer/api/types'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { ListContext } from '/@/renderer/context/list-context'; import { PlaylistListContent } from '/@/renderer/features/playlists/components/playlist-list-content'; @@ -16,7 +16,7 @@ const PlaylistListRoute = () => { const server = useCurrentServer(); const { playlistId } = useParams(); const pageKey = 'playlist'; - const { filter } = useListStoreByKey({ key: pageKey }); + const { filter } = useListStoreByKey({ key: pageKey }); const itemCountCheck = usePlaylistList({ options: { diff --git a/src/renderer/features/search/components/search-header.tsx b/src/renderer/features/search/components/search-header.tsx index 16fc2f8b..214f31cf 100644 --- a/src/renderer/features/search/components/search-header.tsx +++ b/src/renderer/features/search/components/search-header.tsx @@ -5,7 +5,12 @@ import debounce from 'lodash/debounce'; import { useTranslation } from 'react-i18next'; import { generatePath, Link, useParams, useSearchParams } from 'react-router-dom'; import { useCurrentServer } from '../../../store/auth.store'; -import { LibraryItem } from '/@/renderer/api/types'; +import { + AlbumArtistListQuery, + AlbumListQuery, + LibraryItem, + SongListQuery, +} from '/@/renderer/api/types'; import { Button, PageHeader, SearchInput } from '/@/renderer/components'; import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; import { useContainerQuery } from '/@/renderer/hooks'; @@ -24,7 +29,9 @@ export const SearchHeader = ({ tableRef, navigationId }: SearchHeaderProps) => { const [searchParams, setSearchParams] = useSearchParams(); const cq = useContainerQuery(); const server = useCurrentServer(); - const { filter } = useListStoreByKey({ key: itemType }); + const { filter } = useListStoreByKey({ + key: itemType, + }); const { handleRefreshTable } = useListFilterRefresh({ itemType, diff --git a/src/renderer/features/servers/components/add-server-form.tsx b/src/renderer/features/servers/components/add-server-form.tsx index ad7b874f..f17fdbd2 100644 --- a/src/renderer/features/servers/components/add-server-form.tsx +++ b/src/renderer/features/servers/components/add-server-form.tsx @@ -17,7 +17,7 @@ const localSettings = isElectron() ? window.electron.localSettings : null; const SERVER_TYPES = [ { label: 'Jellyfin', value: ServerType.JELLYFIN }, { label: 'Navidrome', value: ServerType.NAVIDROME }, - // { label: 'Subsonic', value: ServerType.SUBSONIC }, + { label: 'Subsonic', value: ServerType.SUBSONIC }, ]; interface AddServerFormProps { diff --git a/src/renderer/features/settings/components/general/control-settings.tsx b/src/renderer/features/settings/components/general/control-settings.tsx index c4e4a32b..ad17f9d9 100644 --- a/src/renderer/features/settings/components/general/control-settings.tsx +++ b/src/renderer/features/settings/components/general/control-settings.tsx @@ -375,28 +375,6 @@ export const ControlSettings = () => { isHidden: !isElectron(), title: t('setting.savePlayQueue', { postProcess: 'sentenceCase' }), }, - { - control: ( - - setSettings({ - general: { - ...settings, - defaultFullPlaylist: e.currentTarget.checked, - }, - }) - } - /> - ), - description: t('setting.skipPlaylistPage', { - context: 'description', - postProcess: 'sentenceCase', - }), - isHidden: false, - title: t('setting.skipPlaylistPage', { postProcess: 'sentenceCase' }), - }, { control: ( { mutationFn: (args) => { const server = getServerById(args.serverId); if (!server) throw new Error('Server not found'); - return api.controller.updateRating({ ...args, apiClientProps: { server } }); + return api.controller.setRating({ ...args, apiClientProps: { server } }); }, onError: (_error, _variables, context) => { for (const item of context?.previous?.items || []) { diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx index 9a92e791..6c1bd85a 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx @@ -11,7 +11,7 @@ import { } from 'react-icons/ri'; import { generatePath } from 'react-router'; import { Link } from 'react-router-dom'; -import { LibraryItem, Playlist } from '/@/renderer/api/types'; +import { LibraryItem, Playlist, PlaylistListSort, SortOrder } from '/@/renderer/api/types'; import { Button, Text } from '/@/renderer/components'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlaylistList } from '/@/renderer/features/playlists'; @@ -24,10 +24,6 @@ import { useCurrentServer, useGeneralSettings, useSettingsStoreActions } from '/ import { openContextMenu } from '/@/renderer/features/context-menu'; import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; -interface SidebarPlaylistListProps { - data: ReturnType['data']; -} - const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => { const { t } = useTranslation(); @@ -66,11 +62,7 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => { } const path = data?.items[index].id - ? data.defaultFullPlaylist - ? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id }) - : generatePath(AppRoute.PLAYLISTS_DETAIL, { - playlistId: data?.items[index].id, - }) + ? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id }) : undefined; return ( @@ -181,12 +173,21 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => { ); }; -export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => { +export const SidebarPlaylistList = () => { const { isScrollbarHidden, hideScrollbarElementProps } = useHideScrollbar(0); const handlePlayQueueAdd = usePlayQueueAdd(); - const { defaultFullPlaylist, sidebarCollapseShared } = useGeneralSettings(); + const { sidebarCollapseShared } = useGeneralSettings(); const { toggleSidebarCollapseShare } = useSettingsStoreActions(); - const { type, username } = useCurrentServer() || {}; + const server = useCurrentServer(); + + const playlistsQuery = usePlaylistList({ + query: { + sortBy: PlaylistListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: server?.id, + }); const [rect, setRect] = useState({ height: 0, @@ -208,10 +209,12 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => { [handlePlayQueueAdd], ); + const data = playlistsQuery.data; + const memoizedItemData = useMemo(() => { - const base = { defaultFullPlaylist, handlePlay: handlePlayPlaylist }; + const base = { handlePlay: handlePlayPlaylist }; - if (!type || !username || !data?.items) { + if (!server?.type || !server?.username || !data?.items) { return { ...base, items: data?.items }; } @@ -219,7 +222,7 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => { const shared: Playlist[] = []; for (const playlist of data.items) { - if (playlist.owner && playlist.owner !== username) { + if (playlist.owner && playlist.owner !== server.username) { shared.push(playlist); } else { owned.push(playlist); @@ -234,12 +237,11 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => { return { ...base, items: final }; }, [ - sidebarCollapseShared, data?.items, - defaultFullPlaylist, handlePlayPlaylist, - type, - username, + server?.type, + server?.username, + sidebarCollapseShared, toggleSidebarCollapseShare, ]); diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index 1ad239cf..22e9650d 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -11,9 +11,9 @@ import { useGeneralSettings, useWindowSettings, } from '../../../store/settings.store'; -import { PlaylistListSort, ServerType, SortOrder } from '/@/renderer/api/types'; -import { Button, MotionStack, Spinner, Tooltip } from '/@/renderer/components'; -import { CreatePlaylistForm, usePlaylistList } from '/@/renderer/features/playlists'; +import { ServerType } from '/@/renderer/api/types'; +import { Button, MotionStack, Tooltip } from '/@/renderer/components'; +import { CreatePlaylistForm } from '/@/renderer/features/playlists'; import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar'; import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon'; import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item'; @@ -110,15 +110,6 @@ export const Sidebar = () => { }); }; - const playlistsQuery = usePlaylistList({ - query: { - sortBy: PlaylistListSort.NAME, - sortOrder: SortOrder.ASC, - startIndex: 0, - }, - serverId: server?.id, - }); - const setFullScreenPlayerStore = useSetFullScreenPlayerStore(); const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore(); const expandFullScreenPlayer = () => { @@ -198,7 +189,6 @@ export const Sidebar = () => { > {t('page.sidebar.playlists', { postProcess: 'titleCase' })} - {playlistsQuery.isLoading && } - + )} diff --git a/src/renderer/features/songs/components/jellyfin-song-filters.tsx b/src/renderer/features/songs/components/jellyfin-song-filters.tsx index 2dc327e6..4c1d7d3a 100644 --- a/src/renderer/features/songs/components/jellyfin-song-filters.tsx +++ b/src/renderer/features/songs/components/jellyfin-song-filters.tsx @@ -1,7 +1,7 @@ import { ChangeEvent, useMemo } from 'react'; import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; -import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types'; import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components'; import { useGenreList } from '/@/renderer/features/genres'; import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; @@ -22,7 +22,7 @@ export const JellyfinSongFilters = ({ }: JellyfinSongFiltersProps) => { const { t } = useTranslation(); const { setFilter } = useListStoreActions(); - const filter = useListFilterByKey({ key: pageKey }); + const filter = useListFilterByKey({ key: pageKey }); const isGenrePage = customFilters?._custom?.jellyfin?.GenreIds !== undefined; @@ -61,16 +61,16 @@ export const JellyfinSongFilters = ({ jellyfin: { ...filter?._custom?.jellyfin, IncludeItemTypes: 'Audio', - IsFavorite: e.currentTarget.checked ? true : undefined, }, }, + favorite: e.currentTarget.checked ? true : undefined, }, itemType: LibraryItem.SONG, key: pageKey, }) as SongListFilter; onFilterChange(updatedFilters); }, - value: filter?._custom?.jellyfin?.IsFavorite, + value: filter.favorite, }, ]; @@ -84,9 +84,9 @@ export const JellyfinSongFilters = ({ jellyfin: { ...filter?._custom?.jellyfin, IncludeItemTypes: 'Audio', - minYear: e === '' ? undefined : (e as number), }, }, + minYear: e === '' ? undefined : (e as number), }, itemType: LibraryItem.SONG, key: pageKey, @@ -104,9 +104,9 @@ export const JellyfinSongFilters = ({ jellyfin: { ...filter?._custom?.jellyfin, IncludeItemTypes: 'Audio', - maxYear: e === '' ? undefined : (e as number), }, }, + maxYear: e === '' ? undefined : (e as number), }, itemType: LibraryItem.SONG, key: pageKey, @@ -115,7 +115,6 @@ export const JellyfinSongFilters = ({ }, 500); const handleGenresFilter = debounce((e: string[] | undefined) => { - const genreFilterString = e?.length ? e.join(',') : undefined; const updatedFilters = setFilter({ customFilters, data: { @@ -123,10 +122,10 @@ export const JellyfinSongFilters = ({ ...filter?._custom, jellyfin: { ...filter?._custom?.jellyfin, - GenreIds: genreFilterString, IncludeItemTypes: 'Audio', }, }, + genreIds: e, }, itemType: LibraryItem.SONG, key: pageKey, @@ -151,18 +150,19 @@ export const JellyfinSongFilters = ({ diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index 96cce995..f8be6b83 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -1,7 +1,7 @@ import { ChangeEvent, useMemo } from 'react'; import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; -import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types'; import { NumberInput, Select, Switch, Text } from '/@/renderer/components'; import { useGenreList } from '/@/renderer/features/genres'; import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; @@ -22,9 +22,9 @@ export const NavidromeSongFilters = ({ }: NavidromeSongFiltersProps) => { const { t } = useTranslation(); const { setFilter } = useListStoreActions(); - const filter = useListFilterByKey({ key: pageKey }); + const filter = useListFilterByKey({ key: pageKey }); - const isGenrePage = customFilters?._custom?.navidrome?.genre_id !== undefined; + const isGenrePage = customFilters?.genreIds !== undefined; const genreListQuery = useGenreList({ query: { @@ -47,12 +47,8 @@ export const NavidromeSongFilters = ({ const updatedFilters = setFilter({ customFilters, data: { - _custom: { - ...filter._custom, - navidrome: { - genre_id: e || undefined, - }, - }, + _custom: filter._custom, + genreIds: e ? [e] : undefined, }, itemType: LibraryItem.SONG, key: pageKey, @@ -68,12 +64,8 @@ export const NavidromeSongFilters = ({ const updatedFilters = setFilter({ customFilters, data: { - _custom: { - ...filter._custom, - navidrome: { - starred: e.currentTarget.checked ? true : undefined, - }, - }, + _custom: filter._custom, + favorite: e.currentTarget.checked ? true : undefined, }, itemType: LibraryItem.SONG, key: pageKey, @@ -81,7 +73,7 @@ export const NavidromeSongFilters = ({ onFilterChange(updatedFilters); }, - value: filter._custom?.navidrome?.starred, + value: filter.favorite, }, ]; @@ -133,7 +125,7 @@ export const NavidromeSongFilters = ({ clearable searchable data={genreList} - defaultValue={filter._custom?.navidrome?.genre_id} + defaultValue={filter.genreIds ? filter.genreIds[0] : undefined} label={t('entity.genre', { count: 1, postProcess: 'titleCase' })} width={150} onChange={handleGenresFilter} diff --git a/src/renderer/features/songs/components/song-list-grid-view.tsx b/src/renderer/features/songs/components/song-list-grid-view.tsx index 8adfaddf..679182af 100644 --- a/src/renderer/features/songs/components/song-list-grid-view.tsx +++ b/src/renderer/features/songs/components/song-list-grid-view.tsx @@ -36,7 +36,7 @@ export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps) 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(); @@ -174,9 +174,9 @@ export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps) const query: SongListQuery = { imageSize: 250, limit: take, - startIndex: skip, ...filter, ...customFilters, + startIndex: skip, }; const queryKey = queryKeys.songs.list(server?.id || '', query, id); diff --git a/src/renderer/features/songs/components/song-list-header-filters.tsx b/src/renderer/features/songs/components/song-list-header-filters.tsx index a7466dc7..382aa289 100644 --- a/src/renderer/features/songs/components/song-list-header-filters.tsx +++ b/src/renderer/features/songs/components/song-list-header-filters.tsx @@ -15,7 +15,13 @@ import { } from 'react-icons/ri'; import { useListStoreByKey } from '../../../store/list.store'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { LibraryItem, ServerType, SongListSort, SortOrder } from '/@/renderer/api/types'; +import { + LibraryItem, + ServerType, + SongListQuery, + SongListSort, + SortOrder, +} from '/@/renderer/api/types'; import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; @@ -29,6 +35,7 @@ import { queryClient } from '/@/renderer/lib/react-query'; import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store'; import { ListDisplayType, Play, TableColumn } from '/@/renderer/types'; import i18n from '/@/i18n/i18n'; +import { SubsonicSongFilters } from '/@/renderer/features/songs/components/subsonic-song-filter'; const FILTERS = { jellyfin: [ @@ -165,25 +172,39 @@ const FILTERS = { value: SongListSort.YEAR, }, ], + subsonic: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: SongListSort.NAME, + }, + ], }; interface SongListHeaderFiltersProps { gridRef: MutableRefObject; + itemCount?: number; tableRef: MutableRefObject; } -export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFiltersProps) => { +export const SongListHeaderFilters = ({ + gridRef, + itemCount, + tableRef, +}: SongListHeaderFiltersProps) => { const { t } = useTranslation(); const server = useCurrentServer(); const { pageKey, handlePlay, customFilters } = useListContext(); - const { display, table, filter, grid } = useListStoreByKey({ + const { display, table, filter, grid } = useListStoreByKey({ filter: customFilters, key: pageKey, }); + const { setFilter, setGrid, setTable, setTablePagination, setDisplayType } = useListStoreActions(); const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.SONG, server, }); @@ -392,25 +413,32 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte }; const handleOpenFiltersModal = () => { + let FilterComponent; + + switch (server?.type) { + case ServerType.NAVIDROME: + FilterComponent = NavidromeSongFilters; + break; + case ServerType.JELLYFIN: + FilterComponent = JellyfinSongFilters; + break; + case ServerType.SUBSONIC: + FilterComponent = SubsonicSongFilters; + break; + } + + if (!FilterComponent) { + return; + } + openModal({ children: ( - <> - {server?.type === ServerType.NAVIDROME ? ( - - ) : ( - - )} - + ), title: 'Song Filters', }); @@ -429,8 +457,16 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte .filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio .some((value) => value !== undefined); - return isNavidromeFilterApplied || isJellyfinFilterApplied; - }, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]); + const isGenericFilterApplied = filter?.favorite || filter?.genreIds?.length; + + return isNavidromeFilterApplied || isJellyfinFilterApplied || isGenericFilterApplied; + }, [ + filter._custom?.jellyfin, + filter._custom?.navidrome, + filter?.favorite, + filter?.genreIds?.length, + server?.type, + ]); const isFolderFilterApplied = useMemo(() => { return filter.musicFolderId !== undefined; @@ -467,11 +503,15 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte ))} - - + {server?.type !== ServerType.SUBSONIC && ( + <> + + + + )} {server?.type === ServerType.JELLYFIN && ( <> diff --git a/src/renderer/features/songs/components/song-list-header.tsx b/src/renderer/features/songs/components/song-list-header.tsx index 05d6fb13..055d3ce8 100644 --- a/src/renderer/features/songs/components/song-list-header.tsx +++ b/src/renderer/features/songs/components/song-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 { LibraryItem, SongListQuery } from '/@/renderer/api/types'; import { PageHeader, SearchInput } from '/@/renderer/components'; import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; import { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters'; @@ -33,12 +33,15 @@ export const SongListHeader = ({ const cq = useContainerQuery(); const genreRef = useRef(); - const { customFilters, filter, handlePlay, refresh, search } = useDisplayRefresh({ - gridRef, - itemType: LibraryItem.SONG, - server, - tableRef, - }); + const { customFilters, filter, handlePlay, refresh, search } = useDisplayRefresh( + { + gridRef, + itemCount, + itemType: LibraryItem.SONG, + server, + tableRef, + }, + ); const handleSearch = debounce((e: ChangeEvent) => { const updatedFilters = search(e) as SongListFilter; @@ -96,6 +99,7 @@ export const SongListHeader = ({ 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..f32c6c4d 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,23 +69,16 @@ 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 }) => { 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; + } + + if (results?.totalRecordCount === null) { + const hasMoreRows = results?.items?.length === BLOCK_SIZE; + const lastRowIndex = hasMoreRows + ? undefined + : (filter.offset || 0) + results.items.length; + + params.successCallback( + results?.items || [], + hasMoreRows ? undefined : lastRowIndex, + ); return; } - params.successCallback(res?.items || [], res?.totalRecordCount || 0); + params.successCallback(results?.items || [], results?.totalRecordCount || 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 db64dece..5003fe79 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; @@ -367,7 +366,6 @@ const initialState: SettingsState = { albumBackgroundBlur: 6, artistItems, buttonSize: 20, - defaultFullPlaylist: true, disabledContextMenu: {}, doubleClickQueueAll: true, externalLinks: true, From c03819b7ec5b73447947eccfaea5fae5e37dcd7a Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 22 Sep 2024 20:07:44 -0700 Subject: [PATCH 3/8] pain --- src/renderer/api/subsonic/subsonic-api.ts | 104 ++ .../api/subsonic/subsonic-controller.ts | 987 +++++++++++++++++- .../api/subsonic/subsonic-normalize.ts | 69 +- src/renderer/api/subsonic/subsonic-types.ts | 242 +++++ src/renderer/api/types.ts | 180 +++- .../components/subsonic-album-filters.tsx | 143 +++ .../album-artist-detail-content.tsx | 8 +- .../context-menu/context-menu-provider.tsx | 17 +- .../playlist-detail-song-list-content.tsx | 11 +- ...aylist-detail-song-list-header-filters.tsx | 62 ++ .../songs/components/subsonic-song-filter.tsx | 109 ++ 11 files changed, 1873 insertions(+), 59 deletions(-) create mode 100644 src/renderer/features/albums/components/subsonic-album-filters.tsx create mode 100644 src/renderer/features/songs/components/subsonic-song-filter.tsx 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 9c400e81..2ecb5b98 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1,34 +1,57 @@ +import dayjs from 'dayjs'; +import filter from 'lodash/filter'; +import orderBy from 'lodash/orderBy'; import md5 from 'md5'; import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize'; -import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types'; -import { LibraryItem, Song, ControllerEndpoint } from '/@/renderer/api/types'; +import { AlbumListSortType, SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types'; +import { + LibraryItem, + Song, + ControllerEndpoint, + sortSongList, + sortAlbumArtistList, + PlaylistListSort, + GenreListSort, + AlbumListSort, + sortAlbumList, +} from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; import { ServerFeatures } from '/@/renderer/api/features-types'; -export const SubsonicController: Omit< - ControllerEndpoint, - | 'addToPlaylist' - | 'createPlaylist' - | 'deletePlaylist' - | 'getAlbumArtistListCount' - | 'getAlbumArtistDetail' - | 'getAlbumArtistList' - | 'getAlbumDetail' - | 'getAlbumList' - | 'getAlbumListCount' - | 'getGenreList' - | 'getPlaylistDetail' - | 'getPlaylistList' - | 'getPlaylistListCount' - | 'getPlaylistSongList' - | 'getSongDetail' - | 'getSongList' - | 'getSongListCount' - | 'movePlaylistItem' - | 'removeFromPlaylist' - | 'updatePlaylist' -> = { +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, +}; + +export const SubsonicController: ControllerEndpoint = { + addToPlaylist: async ({ body, query, apiClientProps }) => { + const res = await ssApiClient(apiClientProps).updatePlaylist({ + query: { + playlistId: query.id, + songIdToAdd: body.songId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to add to playlist'); + } + + return null; + }, authenticate: async (url, body) => { let credential: string; let credentialParams: { @@ -38,7 +61,7 @@ export const SubsonicController: Omit< u: string; }; - const cleanServerUrl = url.replace(/\/$/, ''); + const cleanServerUrl = `${url.replace(/\/$/, '')}/rest`; if (body.legacy) { credential = `u=${body.username}&p=${body.password}`; @@ -89,6 +112,22 @@ export const SubsonicController: Omit< 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; @@ -106,22 +145,364 @@ export const SubsonicController: Omit< return null; }, - // getArtistInfo: async (args) => { - // const { query, apiClientProps } = args; + 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 res = await ssApiClient(apiClientProps).getArtistInfo({ - // query: { - // count: query.limit, - // id: query.artistId, - // }, - // }); + 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; - // if (res.status !== 200) { - // throw new Error('Failed to get artist info'); - // } + 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, + }; + } - // return res.body; - // }, + 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; @@ -133,6 +514,41 @@ export const SubsonicController: Omit< '&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; @@ -148,6 +564,114 @@ export const SubsonicController: Omit< 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; @@ -173,6 +697,20 @@ export const SubsonicController: Omit< totalRecordCount: res.body.randomSongs?.song?.length || 0, }; }, + 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; + }, getServerInfo: async (args) => { const { apiClientProps } = args; @@ -233,6 +771,359 @@ export const SubsonicController: Omit< 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; + + 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; @@ -371,6 +1262,24 @@ export const SubsonicController: Omit< }); } + 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; }, }; 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 1227b268..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'; @@ -1259,7 +1262,7 @@ export type ControllerEndpoint = { getTopSongs: (args: TopSongListArgs) => Promise; getTranscodingUrl: (args: TranscodingArgs) => string; getUserList?: (args: UserListArgs) => Promise; - movePlaylistItem: (args: MoveItemArgs) => Promise; + movePlaylistItem?: (args: MoveItemArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; scrobble: (args: ScrobbleArgs) => Promise; search: (args: SearchArgs) => Promise; @@ -1267,3 +1270,178 @@ export type ControllerEndpoint = { 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/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx new file mode 100644 index 00000000..7367836d --- /dev/null +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -0,0 +1,143 @@ +import { Divider, Group, Stack } from '@mantine/core'; +import debounce from 'lodash/debounce'; +import { ChangeEvent, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { 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]); + + console.log(genreList); + + 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: AlbumListQuery = {}; + + 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')} + /> + + + + )} + + + ); +}; From 256f687c278dc3d2602cb57fde61b018b06a1a44 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 22 Sep 2024 20:11:42 -0700 Subject: [PATCH 4/8] navidrome types --- src/renderer/api/navidrome.types.ts | 10 +++---- .../api/navidrome/navidrome-controller.ts | 29 ------------------- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/src/renderer/api/navidrome.types.ts b/src/renderer/api/navidrome.types.ts index 6c409742..78dee951 100644 --- a/src/renderer/api/navidrome.types.ts +++ b/src/renderer/api/navidrome.types.ts @@ -199,17 +199,17 @@ export type NDGenreListParams = { NDOrder; export enum NDAlbumListSort { - ALBUM_ARTIST = 'albumArtist', + ALBUM_ARTIST = 'album_artist', ARTIST = 'artist', DURATION = 'duration', NAME = 'name', - PLAY_COUNT = 'playCount', + PLAY_COUNT = 'play_count', PLAY_DATE = 'play_date', RANDOM = 'random', RATING = 'rating', RECENTLY_ADDED = 'recently_added', SONG_COUNT = 'songCount', - STARRED = 'starred', + STARRED = 'starred_at', YEAR = 'max_year', } @@ -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', @@ -353,7 +353,7 @@ export type NDPlaylistListResponse = NDPlaylist[]; export enum NDPlaylistListSort { DURATION = 'duration', NAME = 'name', - OWNER = 'ownerName', + OWNER = 'owner_name', PUBLIC = 'public', SONG_COUNT = 'songCount', UPDATED_AT = 'updatedAt', diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 2035870f..4eb9a1d9 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -14,7 +14,6 @@ import { PlaylistSongListResponse, genreListSortMap, Song, - SongListSort, ControllerEndpoint, } from '../types'; import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils'; @@ -486,34 +485,6 @@ export const NavidromeController: ControllerEndpoint = { throw new Error('Failed to get song list'); } - if ( - (query.sortBy === SongListSort.ALBUM || query.sortBy === SongListSort.ALBUM_ARTIST) && - !query.limit - ) { - const isAlbumArtist = query.sortBy === SongListSort.ALBUM_ARTIST; - - res.body.data.sort((a, b) => { - if (isAlbumArtist) { - const albumDiff = a.album.localeCompare(b.album); - if (albumDiff !== 0) { - return albumDiff; - } - } - - const discDiff = a.discNumber - b.discNumber; - if (discDiff !== 0) { - return discDiff; - } - - const trackDiff = a.trackNumber - b.trackNumber; - if (trackDiff !== 0) { - return trackDiff; - } - - return a.title.localeCompare(b.title); - }); - } - return { items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '', query.imageSize), From 5c7e5887161e0b113a333872673b2298e34652e5 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 23 Sep 2024 07:07:49 -0700 Subject: [PATCH 5/8] playlist fix, support infinite or client side (subsonic) --- .../api/navidrome/navidrome-controller.ts | 1 + .../add-to-playlist-context-modal.tsx | 7 +- .../playlist-detail-song-list-content.tsx | 96 +++++++++++-------- ...aylist-detail-song-list-header-filters.tsx | 86 ++++++++++------- .../playlist-list-header-filters.tsx | 32 +++++++ .../components/playlist-list-header.tsx | 1 + .../queries/playlist-song-list-query.ts | 33 +------ .../playlist-detail-song-list-route.tsx | 16 ++-- 8 files changed, 153 insertions(+), 119 deletions(-) diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 4eb9a1d9..641e0cde 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -337,6 +337,7 @@ export const NavidromeController: ControllerEndpoint = { id: query.id, }, query: { + _end: query.limit, _order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC', _sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] diff --git a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx index f0844479..c20c76ac 100644 --- a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx +++ b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx @@ -151,7 +151,12 @@ export const AddToPlaylistContextModal = ({ server, signal, }, - query: { id: playlistId, startIndex: 0 }, + query: { + id: playlistId, + sortBy: SongListSort.ID, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, }); }); diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx index db3a3d12..7dce12c3 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx @@ -49,10 +49,11 @@ import { useAppFocus } from '/@/renderer/hooks'; import { toast } from '/@/renderer/components'; interface PlaylistDetailContentProps { + songs?: Song[]; tableRef: MutableRefObject; } -export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailContentProps) => { +export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetailContentProps) => { const { playlistId } = useParams() as { playlistId: string }; const queryClient = useQueryClient(); const status = useCurrentStatus(); @@ -85,7 +86,12 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED; + const iSClientSide = server?.type === ServerType.SUBSONIC; + const checkPlaylistList = usePlaylistSongList({ + options: { + enabled: !iSClientSide, + }, query: { id: playlistId, limit: 1, @@ -101,44 +107,51 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten const onGridReady = useCallback( (params: GridReadyEvent) => { - const dataSource: IDatasource = { - getRows: async (params) => { - const limit = params.endRow - params.startRow; - const startIndex = params.startRow; - - const query: PlaylistSongListQuery = { - id: playlistId, - limit, - startIndex, - ...filters, - }; - - const queryKey = queryKeys.playlists.songList( - server?.id || '', - playlistId, - query, - ); - - if (!server) return; - - const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) => - api.controller.getPlaylistSongList({ - apiClientProps: { - server, - signal, - }, + if (!iSClientSide) { + const dataSource: IDatasource = { + getRows: async (params) => { + const limit = params.endRow - params.startRow; + const startIndex = params.startRow; + + const query: PlaylistSongListQuery = { + id: playlistId, + limit, + startIndex, + ...filters, + }; + + const queryKey = queryKeys.playlists.songList( + server?.id || '', + playlistId, query, - }), - ); - - params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0); - }, - rowCount: undefined, - }; - params.api.setDatasource(dataSource); + ); + + if (!server) return; + + const songsRes = await queryClient.fetchQuery( + queryKey, + async ({ signal }) => + api.controller.getPlaylistSongList({ + apiClientProps: { + server, + signal, + }, + query, + }), + ); + + params.successCallback( + songsRes?.items || [], + songsRes?.totalRecordCount || 0, + ); + }, + rowCount: undefined, + }; + params.api.setDatasource(dataSource); + } params.api?.ensureIndexVisible(pagination.scrollOffset, 'top'); }, - [filters, pagination.scrollOffset, playlistId, queryClient, server], + [filters, iSClientSide, pagination.scrollOffset, playlistId, queryClient, server], ); const handleDragEnd = useCallback( @@ -271,9 +284,7 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten const { rowClassRules } = useCurrentSongRowStyles({ tableRef }); const canDrag = - filters.sortBy === SongListSort.ID && - !detailQuery?.data?.rules && - server?.type !== ServerType.SUBSONIC; + filters.sortBy === SongListSort.ID && !detailQuery?.data?.rules && !iSClientSide; return ( <> @@ -294,14 +305,17 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten status, }} getRowId={(data) => data.data.uniqueId} - infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100} + infiniteInitialRowCount={ + iSClientSide ? undefined : checkPlaylistList.data?.totalRecordCount || 100 + } pagination={isPaginationEnabled} paginationAutoPageSize={isPaginationEnabled} paginationPageSize={pagination.itemsPerPage || 100} rowClassRules={rowClassRules} + rowData={songs} rowDragEntireRow={canDrag} rowHeight={page.table.rowHeight || 40} - rowModelType="infinite" + rowModelType={iSClientSide ? 'clientSide' : 'infinite'} onBodyScrollEnd={handleScroll} onCellContextMenu={handleContextMenu} onColumnMoved={handleColumnChange} diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx index 4470e340..2d50d12a 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx @@ -303,43 +303,55 @@ export const PlaylistDetailSongListHeaderFilters = ({ const handleFilterChange = useCallback( async (filters: SongListFilter) => { - const dataSource: IDatasource = { - getRows: async (params) => { - const limit = params.endRow - params.startRow; - const startIndex = params.startRow; - - const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, { - id: playlistId, - limit, - startIndex, - ...filters, - }); - - const songsRes = await queryClient.fetchQuery( - queryKey, - async ({ signal }) => - api.controller.getPlaylistSongList({ - apiClientProps: { - server, - signal, - }, - query: { - id: playlistId, - limit, - startIndex, - ...filters, - }, - }), - { cacheTime: 1000 * 60 * 1 }, - ); - - params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0); - }, - rowCount: undefined, - }; - tableRef.current?.api.setDatasource(dataSource); - tableRef.current?.api.purgeInfiniteCache(); - tableRef.current?.api.ensureIndexVisible(0, 'top'); + if (server?.type !== ServerType.SUBSONIC) { + const dataSource: IDatasource = { + getRows: async (params) => { + const limit = params.endRow - params.startRow; + const startIndex = params.startRow; + + const queryKey = queryKeys.playlists.songList( + server?.id || '', + playlistId, + { + id: playlistId, + limit, + startIndex, + ...filters, + }, + ); + + const songsRes = await queryClient.fetchQuery( + queryKey, + async ({ signal }) => + api.controller.getPlaylistSongList({ + apiClientProps: { + server, + signal, + }, + query: { + id: playlistId, + limit, + startIndex, + ...filters, + }, + }), + { cacheTime: 1000 * 60 * 1 }, + ); + + params.successCallback( + songsRes?.items || [], + songsRes?.totalRecordCount || 0, + ); + }, + rowCount: undefined, + }; + tableRef.current?.api.setDatasource(dataSource); + tableRef.current?.api.purgeInfiniteCache(); + tableRef.current?.api.ensureIndexVisible(0, 'top'); + } else { + tableRef.current?.api.redrawRows(); + tableRef.current?.api.ensureIndexVisible(0, 'top'); + } if (page.display === ListDisplayType.TABLE_PAGINATED) { setPagination({ data: { currentPage: 0 } }); diff --git a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx index 470cd6c5..cecd5634 100644 --- a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx @@ -69,6 +69,38 @@ const FILTERS = { value: PlaylistListSort.UPDATED_AT, }, ], + subsonic: [ + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.duration', { postProcess: 'titleCase' }), + value: PlaylistListSort.DURATION, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: PlaylistListSort.NAME, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.owner', { postProcess: 'titleCase' }), + value: PlaylistListSort.OWNER, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.isPublic', { postProcess: 'titleCase' }), + value: PlaylistListSort.PUBLIC, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.songCount', { postProcess: 'titleCase' }), + value: PlaylistListSort.SONG_COUNT, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.recentlyUpdated', { postProcess: 'titleCase' }), + value: PlaylistListSort.UPDATED_AT, + }, + ], }; interface PlaylistListHeaderFiltersProps { diff --git a/src/renderer/features/playlists/components/playlist-list-header.tsx b/src/renderer/features/playlists/components/playlist-list-header.tsx index d8fcb46d..ccb57dd6 100644 --- a/src/renderer/features/playlists/components/playlist-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header.tsx @@ -39,6 +39,7 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis const { filter, refresh, search } = useDisplayRefresh({ gridRef, + itemCount, itemType: LibraryItem.PLAYLIST, server, tableRef, diff --git a/src/renderer/features/playlists/queries/playlist-song-list-query.ts b/src/renderer/features/playlists/queries/playlist-song-list-query.ts index 8d2699d7..423a6136 100644 --- a/src/renderer/features/playlists/queries/playlist-song-list-query.ts +++ b/src/renderer/features/playlists/queries/playlist-song-list-query.ts @@ -1,6 +1,6 @@ -import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { queryKeys } from '/@/renderer/api/query-keys'; -import type { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/renderer/api/types'; +import type { PlaylistSongListQuery } from '/@/renderer/api/types'; import type { QueryHookArgs } from '/@/renderer/lib/react-query'; import { getServerById } from '/@/renderer/store'; import { api } from '/@/renderer/api'; @@ -22,32 +22,3 @@ export const usePlaylistSongList = (args: QueryHookArgs) ...options, }); }; - -export const usePlaylistSongListInfinite = (args: QueryHookArgs) => { - const { options, query, serverId } = args || {}; - const server = getServerById(serverId); - - return useInfiniteQuery({ - enabled: !!server, - getNextPageParam: (lastPage: PlaylistSongListResponse | undefined, pages) => { - if (!lastPage?.items) return undefined; - if (lastPage?.items?.length >= (query?.limit || 50)) { - return pages?.length; - } - - return undefined; - }, - queryFn: ({ pageParam = 0, signal }) => { - return api.controller.getPlaylistSongList({ - apiClientProps: { server, signal }, - query: { - ...query, - limit: query.limit || 50, - startIndex: pageParam * (query.limit || 50), - }, - }); - }, - queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query), - ...options, - }); -}; diff --git a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx index 019f6ce8..b4a4ff17 100644 --- a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx +++ b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx @@ -144,10 +144,6 @@ const PlaylistDetailSongListRoute = () => { }; const itemCountCheck = usePlaylistSongList({ - options: { - cacheTime: 1000 * 60 * 60 * 2, - staleTime: 1000 * 60 * 60 * 2, - }, query: { id: playlistId, limit: 1, @@ -157,10 +153,7 @@ const PlaylistDetailSongListRoute = () => { serverId: server?.id, }); - const itemCount = - itemCountCheck.data?.totalRecordCount === null - ? undefined - : itemCountCheck.data?.totalRecordCount; + const itemCount = itemCountCheck.data?.totalRecordCount || itemCountCheck.data?.items.length; return ( @@ -207,7 +200,12 @@ const PlaylistDetailSongListRoute = () => { )} - + ); }; From b50979411c493577f9561376fa1bd44d1e1a8569 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 23 Sep 2024 21:50:08 -0700 Subject: [PATCH 6/8] fix virtual table for songs, nan, restrict song list filters --- .../api/subsonic/subsonic-controller.ts | 30 +++++++++---------- .../virtual-table/hooks/use-virtual-table.ts | 3 +- src/renderer/features/player/utils.ts | 7 +++-- .../songs/components/subsonic-song-filter.tsx | 13 ++++---- .../features/songs/routes/song-list-route.tsx | 2 +- 5 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 2ecb5b98..7c294698 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -697,20 +697,6 @@ export const SubsonicController: ControllerEndpoint = { totalRecordCount: res.body.randomSongs?.song?.length || 0, }; }, - 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; - }, getServerInfo: async (args) => { const { apiClientProps } = args; @@ -987,7 +973,7 @@ export const SubsonicController: ControllerEndpoint = { throw new Error('Failed to get song list count'); } - const songCount = res.body.searchResult3.song?.length; + const songCount = res.body.searchResult3.song?.length || 0; totalRecordCount += songCount; startIndex += songCount; @@ -1201,6 +1187,20 @@ export const SubsonicController: ControllerEndpoint = { 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; 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 63acc33d..d16c24b3 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -194,7 +194,7 @@ export const useVirtualTable = >({ const hasMoreRows = results?.items?.length === BLOCK_SIZE; const lastRowIndex = hasMoreRows ? undefined - : (properties.filter.offset || 0) + results.items.length; + : params.startRow + results.items.length; params.successCallback( results?.items || [], @@ -342,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, diff --git a/src/renderer/features/player/utils.ts b/src/renderer/features/player/utils.ts index 2e319d95..55b47fe4 100644 --- a/src/renderer/features/player/utils.ts +++ b/src/renderer/features/player/utils.ts @@ -192,14 +192,15 @@ export const getSongsByQuery = async (args: { const res = await queryClient.fetchQuery( queryKey, - async ({ signal }) => - api.controller.getSongList({ + async ({ signal }) => { + return api.controller.getSongList({ apiClientProps: { server, signal, }, query: queryFilter, - }), + }); + }, { cacheTime: 1000 * 60, staleTime: 1000 * 60, diff --git a/src/renderer/features/songs/components/subsonic-song-filter.tsx b/src/renderer/features/songs/components/subsonic-song-filter.tsx index 41543cc4..fae5546b 100644 --- a/src/renderer/features/songs/components/subsonic-song-filter.tsx +++ b/src/renderer/features/songs/components/subsonic-song-filter.tsx @@ -1,7 +1,7 @@ import { ChangeEvent, useMemo } from 'react'; import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; -import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types'; import { Select, Switch, Text } from '/@/renderer/components'; import { useGenreList } from '/@/renderer/features/genres'; import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; @@ -22,9 +22,9 @@ export const SubsonicSongFilters = ({ }: SubsonicSongFiltersProps) => { const { t } = useTranslation(); const { setFilter } = useListStoreActions(); - const filter = useListFilterByKey({ key: pageKey }); + const filter = useListFilterByKey({ key: pageKey }); - const isGenrePage = customFilters?._custom?.navidrome?.genre_id !== undefined; + const isGenrePage = customFilters?.genreIds !== undefined; const genreListQuery = useGenreList({ query: { @@ -58,6 +58,7 @@ export const SubsonicSongFilters = ({ const toggleFilters = [ { + disabled: filter.genreIds !== undefined || isGenrePage || !!filter.searchTerm, label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), onChange: (e: ChangeEvent) => { const updatedFilters = setFilter({ @@ -71,7 +72,7 @@ export const SubsonicSongFilters = ({ onFilterChange(updatedFilters); }, - value: filter.isFavorite, + value: filter.favorite, }, ]; @@ -85,6 +86,7 @@ export const SubsonicSongFilters = ({ {filter.label} @@ -97,7 +99,8 @@ export const SubsonicSongFilters = ({ clearable searchable data={genreList} - defaultValue={filter.genre} + defaultValue={filter.genreIds ? filter.genreIds[0] : undefined} + disabled={!!filter.searchTerm} label={t('entity.genre', { count: 1, postProcess: 'titleCase' })} width={150} onChange={handleGenresFilter} diff --git a/src/renderer/features/songs/routes/song-list-route.tsx b/src/renderer/features/songs/routes/song-list-route.tsx index f32c6c4d..99266318 100644 --- a/src/renderer/features/songs/routes/song-list-route.tsx +++ b/src/renderer/features/songs/routes/song-list-route.tsx @@ -84,7 +84,7 @@ const TrackListRoute = () => { 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?.({ From f3e903c4734000afdf860f472a39915713f8e898 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 25 Sep 2024 21:09:50 -0700 Subject: [PATCH 7/8] Remove console log --- .../features/albums/components/subsonic-album-filters.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx index 7367836d..4e96a80e 100644 --- a/src/renderer/features/albums/components/subsonic-album-filters.tsx +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -39,8 +39,6 @@ export const SubsonicAlbumFilters = ({ })); }, [genreListQuery.data]); - console.log(genreList); - const handleGenresFilter = debounce((e: string | null) => { const updatedFilters = setFilter({ data: { From 0eadc54c6b34fe2dd3880939c8462e3c032db88d Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 25 Sep 2024 21:10:40 -0700 Subject: [PATCH 8/8] Fix type of year filter --- .../features/albums/components/subsonic-album-filters.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx index 4e96a80e..bd1eb57d 100644 --- a/src/renderer/features/albums/components/subsonic-album-filters.tsx +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -69,7 +69,7 @@ export const SubsonicAlbumFilters = ({ ]; const handleYearFilter = debounce((e: number | string, type: 'min' | 'max') => { - let data: AlbumListQuery = {}; + let data: Partial = {}; if (type === 'min') { data = { @@ -131,7 +131,7 @@ export const SubsonicAlbumFilters = ({ searchable data={genreList} defaultValue={filter.genres?.length ? filter.genres[0] : undefined} - disabled={filter.minYear || filter.maxYear} + disabled={Boolean(filter.minYear || filter.maxYear)} label={t('entity.genre', { count: 1, postProcess: 'titleCase' })} onChange={handleGenresFilter} />