From ca695ca155a83711a4f71875cfffa0267464921c Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 3 Dec 2023 16:45:30 -0800 Subject: [PATCH 01/23] Add all relevant subsonic endpoints to ts-rest --- src/renderer/api/subsonic/subsonic-api.ts | 407 +++++++- .../api/subsonic/subsonic-normalize.ts | 26 +- src/renderer/api/subsonic/subsonic-types.ts | 975 +++++++++++++++--- 3 files changed, 1240 insertions(+), 168 deletions(-) diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 5a620f19a..fa5ad2c18 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -1,93 +1,426 @@ import { initClient, initContract } from '@ts-rest/core'; -import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios'; +import axios, { AxiosError, AxiosResponse, Method, isAxiosError } from 'axios'; import omitBy from 'lodash/omitBy'; import qs from 'qs'; -import { z } from 'zod'; -import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; +import i18n from '/@/i18n/i18n'; +import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; import { ServerListItem } from '/@/renderer/api/types'; import { toast } from '/@/renderer/components/toast/index'; -import i18n from '/@/i18n/i18n'; const c = initContract(); export const contract = c.router({ - authenticate: { + changePassword: { method: 'GET', - path: 'ping.view', - query: ssType._parameters.authenticate, + path: 'changePassword.view', + query: SubsonicApi.changePassword.parameters, responses: { - 200: ssType._response.authenticate, + 200: SubsonicApi.changePassword.response, }, }, - createFavorite: { + createInternetRadioStation: { method: 'GET', - path: 'star.view', - query: ssType._parameters.createFavorite, + path: 'createInternetRadioStation.view', + query: SubsonicApi.createInternetRadioStation.parameters, + responses: { + 200: SubsonicApi.createInternetRadioStation.response, + }, + }, + createPlaylist: { + method: 'GET', + path: 'createPlaylist.view', + query: SubsonicApi.createPlaylist.parameters, + responses: { + 200: SubsonicApi.createPlaylist.response, + }, + }, + createShare: { + method: 'GET', + path: 'createShare.view', + query: SubsonicApi.createShare.parameters, responses: { - 200: ssType._response.createFavorite, + 200: SubsonicApi.createShare.response, + }, + }, + createUser: { + method: 'GET', + path: 'createUser.view', + query: SubsonicApi.createUser.parameters, + responses: { + 200: SubsonicApi.createUser.response, + }, + }, + deleteInternetRadioStation: { + method: 'GET', + path: 'deleteInternetRadioStation.view', + query: SubsonicApi.deleteInternetRadioStation.parameters, + responses: { + 200: SubsonicApi.deleteInternetRadioStation.response, + }, + }, + deletePlaylist: { + method: 'GET', + path: 'deletePlaylist.view', + query: SubsonicApi.deletePlaylist.parameters, + responses: { + 200: SubsonicApi.deletePlaylist.response, + }, + }, + deleteShare: { + method: 'GET', + path: 'deleteShare.view', + query: SubsonicApi.deleteShare.parameters, + responses: { + 200: SubsonicApi.deleteShare.response, + }, + }, + deleteUser: { + method: 'GET', + path: 'deleteUser.view', + query: SubsonicApi.deleteUser.parameters, + responses: { + 200: SubsonicApi.deleteUser.response, + }, + }, + getAlbum: { + method: 'GET', + path: 'getAlbum.view', + query: SubsonicApi.getAlbum.parameters, + responses: { + 200: SubsonicApi.getAlbum.response, + }, + }, + getAlbumInfo: { + method: 'GET', + path: 'getAlbumInfo.view', + query: SubsonicApi.getAlbumInfo.parameters, + responses: { + 200: SubsonicApi.getAlbumInfo.response, + }, + }, + getAlbumInfo2: { + method: 'GET', + path: 'getAlbumInfo2.view', + query: SubsonicApi.getAlbumInfo2.parameters, + responses: { + 200: SubsonicApi.getAlbumInfo2.response, + }, + }, + getAlbumList: { + method: 'GET', + path: 'getAlbumList.view', + query: SubsonicApi.getAlbumList.parameters, + responses: { + 200: SubsonicApi.getAlbumList.response, + }, + }, + getAlbumList2: { + method: 'GET', + path: 'getAlbumList2.view', + query: SubsonicApi.getAlbumList2.parameters, + responses: { + 200: SubsonicApi.getAlbumList2.response, + }, + }, + getArtist: { + method: 'GET', + path: 'getArtist.view', + query: SubsonicApi.getArtist.parameters, + responses: { + 200: SubsonicApi.getArtist.response, }, }, getArtistInfo: { method: 'GET', path: 'getArtistInfo.view', - query: ssType._parameters.artistInfo, + query: SubsonicApi.getArtistInfo.parameters, + responses: { + 200: SubsonicApi.getArtistInfo.response, + }, + }, + getArtistInfo2: { + method: 'GET', + path: 'getArtistInfo2.view', + query: SubsonicApi.getArtistInfo2.parameters, + responses: { + 200: SubsonicApi.getArtistInfo2.response, + }, + }, + getArtists: { + method: 'GET', + path: 'getArtists.view', + query: SubsonicApi.getArtists.parameters, + responses: { + 200: SubsonicApi.getArtists.response, + }, + }, + getGenres: { + method: 'GET', + path: 'getGenres.view', + query: SubsonicApi.getGenres.parameters, + responses: { + 200: SubsonicApi.getGenres.response, + }, + }, + getIndexes: { + method: 'GET', + path: 'getIndexes.view', + query: SubsonicApi.getIndexes.parameters, + responses: { + 200: SubsonicApi.getIndexes.response, + }, + }, + getInternetRadioStations: { + method: 'GET', + path: 'getInternetRadioStations.view', + query: SubsonicApi.getInternetRadioStations.parameters, + responses: { + 200: SubsonicApi.getInternetRadioStations.response, + }, + }, + getLicense: { + method: 'GET', + path: 'getLicense.view', + query: SubsonicApi.getLicense.parameters, + responses: { + 200: SubsonicApi.getLicense.response, + }, + }, + getLyrics: { + method: 'GET', + path: 'getLyrics.view', + query: SubsonicApi.getLyrics.parameters, responses: { - 200: ssType._response.artistInfo, + 200: SubsonicApi.getLyrics.response, }, }, - getMusicFolderList: { + getMusicDirectory: { + method: 'GET', + path: 'getMusicDirectory.view', + query: SubsonicApi.getMusicDirectory.parameters, + responses: { + 200: SubsonicApi.getMusicDirectory.response, + }, + }, + getMusicFolders: { method: 'GET', path: 'getMusicFolders.view', responses: { - 200: ssType._response.musicFolderList, + 200: SubsonicApi.getMusicFolders.response, }, }, - getRandomSongList: { + getNowPlaying: { + method: 'GET', + path: 'getNowPlaying.view', + query: SubsonicApi.getNowPlaying.parameters, + responses: { + 200: SubsonicApi.getNowPlaying.response, + }, + }, + getOpenSubsonicExtensions: { + method: 'GET', + path: 'getOpenSubsonicExtensions.view', + query: SubsonicApi.getOpenSubsonicExtensions.parameters, + responses: { + 200: SubsonicApi.getOpenSubsonicExtensions.response, + }, + }, + getPlaylist: { + method: 'GET', + path: 'getPlaylist.view', + query: SubsonicApi.getPlaylist.parameters, + responses: { + 200: SubsonicApi.getPlaylist.response, + }, + }, + getPlaylists: { + method: 'GET', + path: 'getPlaylists.view', + query: SubsonicApi.getPlaylists.parameters, + responses: { + 200: SubsonicApi.getPlaylists.response, + }, + }, + getRandomSongs: { method: 'GET', path: 'getRandomSongs.view', - query: ssType._parameters.randomSongList, + query: SubsonicApi.getRandomSongs.parameters, + responses: { + 200: SubsonicApi.getRandomSongs.response, + }, + }, + getScanStatus: { + method: 'GET', + path: 'getScanStatus.view', + responses: { + 200: SubsonicApi.getScanStatus.response, + }, + }, + getShares: { + method: 'GET', + path: 'getShares.view', + query: SubsonicApi.getShares.parameters, + responses: { + 200: SubsonicApi.getShares.response, + }, + }, + getSimilarSongs: { + method: 'GET', + path: 'getSimilarSongs.view', + query: SubsonicApi.getSimilarSongs.parameters, + responses: { + 200: SubsonicApi.getSimilarSongs.response, + }, + }, + getSimilarSongs2: { + method: 'GET', + path: 'getSimilarSongs2.view', + query: SubsonicApi.getSimilarSongs2.parameters, + responses: { + 200: SubsonicApi.getSimilarSongs2.response, + }, + }, + getSong: { + method: 'GET', + path: 'getSong.view', + query: SubsonicApi.getSong.parameters, + responses: { + 200: SubsonicApi.getSong.response, + }, + }, + getSongsByGenre: { + method: 'GET', + path: 'getSongsByGenre.view', + query: SubsonicApi.getSongsByGenre.parameters, + responses: { + 200: SubsonicApi.getSongsByGenre.response, + }, + }, + getStarred: { + method: 'GET', + path: 'getStarred.view', + query: SubsonicApi.getStarred.parameters, + responses: { + 200: SubsonicApi.getStarred.response, + }, + }, + getStarred2: { + method: 'GET', + path: 'getStarred2.view', + query: SubsonicApi.getStarred2.parameters, responses: { - 200: ssType._response.randomSongList, + 200: SubsonicApi.getStarred2.response, }, }, - getTopSongsList: { + getTopSongs: { method: 'GET', path: 'getTopSongs.view', - query: ssType._parameters.topSongsList, + query: SubsonicApi.getTopSongs.parameters, responses: { - 200: ssType._response.topSongsList, + 200: SubsonicApi.getTopSongs.response, }, }, - removeFavorite: { + getUser: { method: 'GET', - path: 'unstar.view', - query: ssType._parameters.removeFavorite, + path: 'getUser.view', + query: SubsonicApi.getUser.parameters, + responses: { + 200: SubsonicApi.getUser.response, + }, + }, + getUsers: { + method: 'GET', + path: 'getUsers.view', + query: SubsonicApi.getUsers.parameters, + responses: { + 200: SubsonicApi.getUsers.response, + }, + }, + ping: { + method: 'GET', + path: 'ping.view', + query: SubsonicApi.ping.parameters, responses: { - 200: ssType._response.removeFavorite, + 200: SubsonicApi.ping.response, }, }, scrobble: { method: 'GET', path: 'scrobble.view', - query: ssType._parameters.scrobble, + query: SubsonicApi.scrobble.parameters, responses: { - 200: ssType._response.scrobble, + 200: SubsonicApi.scrobble.response, }, }, search3: { method: 'GET', path: 'search3.view', - query: ssType._parameters.search3, + query: SubsonicApi.search3.parameters, responses: { - 200: ssType._response.search3, + 200: SubsonicApi.search3.response, }, }, setRating: { method: 'GET', path: 'setRating.view', - query: ssType._parameters.setRating, + query: SubsonicApi.setRating.parameters, + responses: { + 200: SubsonicApi.setRating.response, + }, + }, + star: { + method: 'GET', + path: 'star.view', + query: SubsonicApi.star.parameters, + responses: { + 200: SubsonicApi.star.response, + }, + }, + startScan: { + method: 'GET', + path: 'startScan.view', responses: { - 200: ssType._response.setRating, + 200: SubsonicApi.startScan.response, + }, + }, + unstar: { + method: 'GET', + path: 'unstar.view', + query: SubsonicApi.unstar.parameters, + responses: { + 200: SubsonicApi.unstar.response, + }, + }, + updateInternetRadioStation: { + method: 'GET', + path: 'updateInternetRadioStation.view', + query: SubsonicApi.updateInternetRadioStation.parameters, + responses: { + 200: SubsonicApi.updateInternetRadioStation.response, + }, + }, + updatePlaylist: { + method: 'GET', + path: 'updatePlaylist.view', + query: SubsonicApi.updatePlaylist.parameters, + responses: { + 200: SubsonicApi.updatePlaylist.response, + }, + }, + updateShare: { + method: 'GET', + path: 'updateShare.view', + query: SubsonicApi.updateShare.parameters, + responses: { + 200: SubsonicApi.updateShare.response, + }, + }, + updateUser: { + method: 'GET', + path: 'updateUser.view', + query: SubsonicApi.updateUser.parameters, + responses: { + 200: SubsonicApi.updateUser.response, }, }, }); @@ -110,6 +443,8 @@ axiosClient.interceptors.response.use( title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string, }); } + + return Promise.reject(data['subsonic-response'].error); } return response; @@ -131,7 +466,7 @@ const parsePath = (fullPath: string) => { }; }; -export const ssApiClient = (args: { +export const subsonicApiClient = (args: { server: ServerListItem | null; signal?: AbortSignal; url?: string; @@ -162,9 +497,7 @@ export const ssApiClient = (args: { } try { - const result = await axiosClient.request< - z.infer - >({ + const result = await axiosClient.request({ data: body, headers, method: method as Method, @@ -180,7 +513,7 @@ export const ssApiClient = (args: { }); return { - body: result.data['subsonic-response'], + body: result.data, headers: result.headers as any, status: result.status, }; diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index 881e7fef2..2328515a5 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -1,7 +1,7 @@ import { nanoid } from 'nanoid'; import { z } from 'zod'; -import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; -import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/types'; +import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; +import { QueueSong, LibraryItem, AlbumArtist, Album, Genre } from '/@/renderer/api/types'; import { ServerListItem, ServerType } from '/@/renderer/types'; const getCoverArtUrl = (args: { @@ -27,7 +27,7 @@ const getCoverArtUrl = (args: { }; const normalizeSong = ( - item: z.infer, + item: z.infer, server: ServerListItem | null, deviceId: string, ): QueueSong => { @@ -105,7 +105,7 @@ const normalizeSong = ( }; const normalizeAlbumArtist = ( - item: z.infer, + item: z.infer, server: ServerListItem | null, ): AlbumArtist => { const imageUrl = @@ -138,7 +138,9 @@ const normalizeAlbumArtist = ( }; const normalizeAlbum = ( - item: z.infer, + item: + | z.infer + | z.infer, server: ServerListItem | null, ): Album => { const imageUrl = @@ -189,8 +191,20 @@ const normalizeAlbum = ( }; }; -export const ssNormalize = { +const normalizeGenre = (item: z.infer): Genre => { + return { + albumCount: item.albumCount, + id: item.value, + imageUrl: null, + itemType: LibraryItem.GENRE, + name: item.value, + songCount: item.songCount, + }; +}; + +export const subsonicNormalize = { album: normalizeAlbum, albumArtist: normalizeAlbumArtist, + genre: normalizeGenre, song: normalizeSong, }; diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index 3360081b6..118622be4 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -1,56 +1,30 @@ import { z } from 'zod'; const baseResponse = z.object({ - 'subsonic-response': z.object({ - status: z.string(), - version: z.string(), - }), -}); - -const authenticate = z.null(); - -const authenticateParameters = z.object({ - c: z.string(), - f: z.string(), - p: z.string().optional(), - s: z.string().optional(), - t: z.string().optional(), - u: z.string(), - v: z.string(), + 'subsonic-response': z + .object({ + status: z.string(), + version: z.string(), + }) + // OpenSubsonic v1.0.0 + .extend({ + openSubsonic: z.boolean().optional(), + serverVersion: z.string().optional(), + type: z.string().optional(), + }), }); -const createFavoriteParameters = z.object({ - albumId: z.array(z.string()).optional(), - artistId: z.array(z.string()).optional(), - id: z.array(z.string()).optional(), -}); - -const createFavorite = z.null(); - -const removeFavoriteParameters = z.object({ - albumId: z.array(z.string()).optional(), - artistId: z.array(z.string()).optional(), - id: z.array(z.string()).optional(), -}); - -const removeFavorite = z.null(); - -const setRatingParameters = z.object({ - id: z.string(), - rating: z.number(), -}); - -const setRating = z.null(); +const baseResponseShape = baseResponse.shape['subsonic-response']; const musicFolder = z.object({ id: z.string(), name: z.string(), }); -const musicFolderList = z.object({ - musicFolders: z.object({ - musicFolder: z.array(musicFolder), - }), +const genre = z.object({ + albumCount: z.number(), + songCount: z.number(), + value: z.string(), }); const song = z.object({ @@ -103,35 +77,82 @@ const album = z.object({ year: z.number().optional(), }); -const albumListParameters = z.object({ - fromYear: z.number().optional(), - genre: z.string().optional(), - musicFolderId: z.string().optional(), - offset: z.number().optional(), - size: z.number().optional(), - toYear: z.number().optional(), - type: z.string().optional(), +const albumListEntry = album.omit({ + song: true, }); -const albumList = z.array(album.omit({ song: true })); +const artist = z + .object({ + album: z.array(album), + albumCount: z.string(), + coverArt: z.string().optional(), + id: z.string(), + name: z.string(), + starred: z.string().optional(), + }) + // Navidrome lastfm extension + .extend({ + artistImageUrl: z.string().optional(), + }); + +const artistListEntry = artist.pick({ + albumCount: true, + coverArt: true, + id: true, + name: true, + starred: true, +}); -const albumArtist = z.object({ - albumCount: z.string(), - artistImageUrl: z.string().optional(), +const playlist = z.object({ + changed: z.string().optional(), + comment: z.string().optional(), coverArt: z.string().optional(), + created: z.string(), + duration: z.number(), + entry: z.array(song), id: z.string(), name: z.string(), + owner: z.string(), + public: z.boolean(), + songCount: z.number(), }); -const albumArtistList = z.object({ - artist: z.array(albumArtist), +const share = z.object({ + created: z.string(), + description: z.string().optional(), + entry: z.array(song), + expires: z.string().optional(), + id: z.string(), + lastVisited: z.string().optional(), name: z.string(), + public: z.boolean(), + url: z.string(), + username: z.string(), + visitCount: z.number(), }); -const artistInfoParameters = z.object({ - count: z.number().optional(), - id: z.string(), - includeNotPresent: z.boolean().optional(), +const user = z.object({ + adminRole: z.boolean(), + commentRole: z.boolean(), + downloadRole: z.boolean(), + email: z.string(), + folder: z.array(z.number()), + jukeboxRole: z.boolean(), + playlistRole: z.boolean(), + podcastRole: z.boolean(), + scrobblingEnabled: z.boolean(), + settingsRole: z.boolean(), + shareRole: z.boolean(), + uploadRole: z.boolean(), + username: z.string(), +}); + +const shareListEntry = share.omit({ + entry: true, +}); + +const playlistListEntry = playlist.omit({ + entry: true, }); const artistInfo = z.object({ @@ -154,87 +175,791 @@ const artistInfo = z.object({ }), }); -const topSongsListParameters = z.object({ - artist: z.string(), // The name of the artist, not the artist ID - count: z.number().optional(), +const albumInfo = z.object({ + largeImageUrl: z.string().optional(), + lastFmUrl: z.string().optional(), + mediumImageUrl: z.string().optional(), + musicBrainzId: z.string().optional(), + notes: z.string().optional(), + smallImageUrl: z.string().optional(), }); -const topSongsList = z.object({ - topSongs: z.object({ - song: z.array(song), +const ping = { + parameters: z.object({}), + response: baseResponse, +}; + +const getLicense = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + license: z.object({ + email: z.string(), + licenseExpires: z.string(), + trialExpires: z.string(), + valid: z.boolean(), + }), + }), }), -}); +}; -const scrobbleParameters = z.object({ - id: z.string(), - submission: z.boolean().optional(), - time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to. -}); +const getOpenSubsonicExtensions = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + openSubsonicExtensions: z.object({ + name: z.string(), + version: z.array(z.number()), + }), + }), + }), +}; -const scrobble = z.null(); +const getMusicFolders = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + musicFolders: z.object({ + musicFolder: z.array(musicFolder), + }), + }), + }), +}; -const search3 = z.object({ - searchResult3: z.object({ - album: z.array(album), - artist: z.array(albumArtist), - song: z.array(song), +const getIndexes = { + parameters: z.object({ + ifModifiedSince: z.number().optional(), + musicFolderId: z.string().optional(), }), -}); + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + indexes: z.object({ + ignoredArticles: z.string(), + index: z.array( + z.object({ + artist: z.array( + z.object({ + albumCount: z.number(), + coverArt: z.string().optional(), + id: z.string(), + name: z.string(), + }), + ), + name: z.string(), + }), + ), + lastModified: z.number(), + }), + }), + }), +}; -const search3Parameters = z.object({ - albumCount: z.number().optional(), - albumOffset: z.number().optional(), - artistCount: z.number().optional(), - artistOffset: z.number().optional(), - musicFolderId: z.string().optional(), - query: z.string().optional(), - songCount: z.number().optional(), - songOffset: z.number().optional(), -}); +const getMusicDirectory = { + parameters: z.object({ + id: z.string(), + musicFolderId: z.string().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + directory: z.object({ + child: z.array( + z.object({ + artist: z.string().optional(), + coverArt: z.string().optional(), + id: z.string(), + isDir: z.boolean(), + parent: z.string(), + title: z.string(), + }), + ), + childCount: z.number(), + coverArt: z.string().optional(), + id: z.string(), + name: z.string(), + parent: z.string(), + }), + }), + }), +}; -const randomSongListParameters = z.object({ - fromYear: z.number().optional(), - genre: z.string().optional(), - musicFolderId: z.string().optional(), - size: z.number().optional(), - toYear: z.number().optional(), -}); +const getGenres = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + genres: z.object({ + genre: z.array(genre), + }), + }), + }), +}; -const randomSongList = z.object({ - randomSongs: z.object({ - song: z.array(song), +const getArtists = { + parameters: z.object({ + musicFolderId: z.string().optional(), }), -}); + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + artists: z.object({ + ignoredArticles: z.string(), + index: z.array( + z.object({ + artist: z.array(artistListEntry), + name: z.string(), + }), + ), + }), + }), + }), +}; + +const getArtist = { + parameters: z.object({ + id: z.string(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + artist, + }), + }), +}; + +const getAlbum = { + parameters: z.object({ + id: z.string(), + musicFolderId: z.string().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + album, + }), + }), +}; + +const getSong = { + parameters: z.object({ + id: z.string(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + song, + }), + }), +}; + +const getArtistInfo = { + parameters: z.object({ + count: z.number().optional(), + id: z.string(), + + // Whether to return artists that are not present in the media library. + includeNotPresent: z.boolean().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + artistInfo, + }), + }), +}; + +const getArtistInfo2 = { + parameters: getArtistInfo.parameters, + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + artistInfo2: artistInfo, + }), + }), +}; + +const getAlbumInfo = { + parameters: z.object({ + id: z.string(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + albumInfo, + }), + }), +}; -export const ssType = { - _parameters: { - albumList: albumListParameters, - artistInfo: artistInfoParameters, - authenticate: authenticateParameters, - createFavorite: createFavoriteParameters, - randomSongList: randomSongListParameters, - removeFavorite: removeFavoriteParameters, - scrobble: scrobbleParameters, - search3: search3Parameters, - setRating: setRatingParameters, - topSongsList: topSongsListParameters, +const getAlbumInfo2 = { + parameters: getAlbumInfo.parameters, + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + albumInfo2: albumInfo, + }), + }), +}; + +const getSimilarSongs = { + parameters: z.object({ + count: z.number().optional(), + id: z.string(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + similarSongs: z.object({ + song: z.array(song), + }), + }), + }), +}; + +const getSimilarSongs2 = { + parameters: getSimilarSongs.parameters, + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + similarSongs2: z.object({ + song: z.array(song), + }), + }), + }), +}; + +const getTopSongs = { + parameters: z.object({ + artist: z.string(), + count: z.number().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + topSongs: z.object({ + song: z.array(song), + }), + }), + }), +}; + +export enum AlbumListSortType { + ALPHABETICAL_BY_ARTIST = 'alphabeticalByArtist', + ALPHABETICAL_BY_NAME = 'alphabeticalByName', + BY_GENRE = 'byGenre', + BY_YEAR = 'byYear', + FREQUENT = 'frequent', + NEWEST = 'newest', + RANDOM = 'random', + RECENT = 'recent', + STARRED = 'starred', +} + +const getAlbumList = { + enum: { + AlbumListSortType, }, - _response: { + parameters: z + .object({ + fromYear: z.number().optional(), + genre: z.string().optional(), + musicFolderId: z.string().optional(), + offset: z.number().optional(), + size: z.number().optional(), + toYear: z.number().optional(), + type: z.nativeEnum(AlbumListSortType), + }) + .refine( + (val) => { + if (val.type === AlbumListSortType.BY_YEAR) { + return val.fromYear !== undefined && val.toYear !== undefined; + } + + return true; + }, + { + message: 'Parameters "fromYear" and "toYear" are required when using sort "byYear"', + }, + ) + .refine( + (val) => { + if (val.type === AlbumListSortType.BY_GENRE) { + return val.genre !== undefined; + } + + return true; + }, + { message: 'Parameter "genre" is required when using sort "byGenre"' }, + ), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + albumList: z.object({ + album: z.array(albumListEntry), + }), + }), + }), +}; + +const getAlbumList2 = { + enum: getAlbumList.enum, + parameters: getAlbumList.parameters, + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + albumList2: z.object({ + album: z.array(albumListEntry), + }), + }), + }), +}; + +const getRandomSongs = { + parameters: z.object({ + fromYear: z.number().optional(), + genre: z.string().optional(), + musicFolderId: z.string().optional(), + size: z.number().optional(), + toYear: z.number().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + randomSongs: z.object({ + song: z.array(song), + }), + }), + }), +}; + +const getSongsByGenre = { + parameters: z.object({ + count: z.number().optional(), + genre: z.string(), + musicFolderId: z.string().optional(), + offset: z.number().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + songsByGenre: z.object({ + song: z.array(song), + }), + }), + }), +}; + +const getNowPlaying = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + nowPlaying: z.object({ + entry: z.array( + z.object({ + minutesAgo: z.number(), + playerId: z.number(), + song, + username: z.string(), + }), + ), + }), + }), + }), +}; + +const getStarred = { + parameters: z.object({ + musicFolderId: z.string().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + starred: z.object({ + album: z.array(albumListEntry), + artist: z.array(artistListEntry), + song: z.array(song), + }), + }), + }), +}; + +const getStarred2 = { + parameters: getStarred.parameters, + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + starred2: z.object({ + album: z.array(albumListEntry), + artist: z.array(artistListEntry), + song: z.array(song), + }), + }), + }), +}; + +const search3 = { + parameters: z.object({ + albumCount: z.number().optional(), + albumOffset: z.number().optional(), + artistCount: z.number().optional(), + artistOffset: z.number().optional(), + musicFolderId: z.string().optional(), + query: z.string(), + songCount: z.number().optional(), + songOffset: z.number().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + searchResult3: z.object({ + album: z.array(albumListEntry), + artist: z.array(artistListEntry), + song: z.array(song), + }), + }), + }), +}; + +const getPlaylists = { + parameters: z.object({ + username: z.string().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + playlists: z.object({ + playlist: z.array(playlistListEntry), + }), + }), + }), +}; + +const getPlaylist = { + parameters: z.object({ + id: z.string(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + playlist, + }), + }), +}; + +const createPlaylist = { + parameters: z.object({ + name: z.string(), + playlistId: z.string().optional(), + songId: z.array(z.string()).optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + playlist, + }), + }), +}; + +const updatePlaylist = { + parameters: z.object({ + comment: z.string().optional(), + name: z.string().optional(), + playlistId: z.string(), + public: z.boolean().optional(), + songIdToAdd: z.array(z.string()).optional(), + songIdToRemove: z.array(z.string()).optional(), + }), + response: baseResponse, +}; + +const deletePlaylist = { + parameters: z.object({ + id: z.string(), + }), + response: baseResponse, +}; + +const getLyrics = { + parameters: z.object({ + artist: z.string().optional(), + title: z.string().optional(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + lyrics: z.object({ + artist: z.string(), + title: z.string(), + value: z.string(), + }), + }), + }), +}; + +const star = { + parameters: z.object({ + albumId: z.array(z.string()).optional(), + artistId: z.array(z.string()).optional(), + id: z.array(z.string()).optional(), + }), + response: baseResponse, +}; + +const unstar = { + parameters: z.object({ + albumId: z.array(z.string()).optional(), + artistId: z.array(z.string()).optional(), + id: z.array(z.string()).optional(), + }), + response: baseResponse, +}; + +const setRating = { + parameters: z.object({ + id: z.string(), + rating: z.number(), + }), + response: baseResponse, +}; + +const scrobble = { + parameters: z.object({ + id: z.string(), + submission: z.boolean().optional(), // Whether this is a “submission” or a “now playing” notification. + time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to. + }), + response: baseResponse, +}; + +const getShares = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + shares: z.object({ + share: z.array(shareListEntry), + }), + }), + }), +}; + +const createShare = { + parameters: z.object({ + description: z.string().optional(), + expires: z.number().optional(), + id: z.array(z.string()), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + shares: z.object({ + share: z.array(shareListEntry), + }), + }), + }), +}; + +const updateShare = { + parameters: z.object({ + description: z.string().optional(), + expires: z.number().optional(), + id: z.string(), + }), + response: baseResponse, +}; + +const deleteShare = { + parameters: z.object({ + id: z.string(), + }), + response: baseResponse, +}; + +const getInternetRadioStations = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + internetRadioStations: z.object({ + entry: z.array( + z.object({ + id: z.string(), + name: z.string(), + streamUrl: z.string(), + }), + ), + }), + }), + }), +}; + +const createInternetRadioStation = { + parameters: z.object({ + homePageUrl: z.string().optional(), + name: z.string(), + streamUrl: z.string(), + }), + response: baseResponse, +}; + +const updateInternetRadioStation = { + parameters: z.object({ + homePageUrl: z.string().optional(), + id: z.string(), + name: z.string(), + streamUrl: z.string(), + }), + response: baseResponse, +}; + +const deleteInternetRadioStation = { + parameters: z.object({ + id: z.string(), + }), + response: baseResponse, +}; + +const getUser = { + parameters: z.object({ + username: z.string(), + }), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + user, + }), + }), +}; + +const getUsers = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + users: z.object({ + user: z.array(user), + }), + }), + }), +}; + +const createUser = { + parameters: z.object({ + adminRole: z.boolean().optional(), + commentRole: z.boolean().optional(), + coverArtRole: z.boolean().optional(), + downloadRole: z.boolean().optional(), + email: z.string(), + folder: z.array(z.number()).optional(), + jukeboxRole: z.boolean().optional(), + ldapAuthenticated: z.boolean().optional(), + musicFolderId: z.array(z.string()).optional(), + password: z.string(), + playlistRole: z.boolean().optional(), + podcastRole: z.boolean().optional(), + scrobblingEnabled: z.boolean().optional(), + settingsRole: z.boolean().optional(), + shareRole: z.boolean().optional(), + streamRole: z.boolean().optional(), + uploadRole: z.boolean().optional(), + username: z.string(), + }), + response: baseResponse, +}; + +const updateUser = { + parameters: z.object({ + adminRole: z.boolean().optional(), + commentRole: z.boolean().optional(), + coverArtRole: z.boolean().optional(), + downloadRole: z.boolean().optional(), + email: z.string().optional(), + folder: z.array(z.number()).optional(), + jukeboxRole: z.boolean().optional(), + ldapAuthenticated: z.boolean().optional(), + musicFolderId: z.array(z.string()).optional(), + password: z.string().optional(), + playlistRole: z.boolean().optional(), + podcastRole: z.boolean().optional(), + scrobblingEnabled: z.boolean().optional(), + settingsRole: z.boolean().optional(), + shareRole: z.boolean().optional(), + streamRole: z.boolean().optional(), + uploadRole: z.boolean().optional(), + username: z.string(), + }), + response: baseResponse, +}; + +const deleteUser = { + parameters: z.object({ + username: z.string(), + }), + response: baseResponse, +}; + +const changePassword = { + parameters: z.object({ + password: z.string(), + }), + response: baseResponse, +}; + +const getScanStatus = { + parameters: z.object({}), + response: z.object({ + 'subsonic-response': baseResponseShape.extend({ + scanStatus: z.object({ + count: z.number(), + scanning: z.boolean(), + }), + }), + }), +}; + +const startScan = { + parameters: z.object({}), + response: baseResponse, +}; + +export const SubsonicApi = { + _baseTypes: { album, - albumArtist, - albumArtistList, - albumList, + albumInfo, + albumListEntry, + artist, artistInfo, - authenticate, + artistListEntry, baseResponse, - createFavorite, - musicFolderList, - randomSongList, - removeFavorite, - scrobble, - search3, - setRating, + genre, + musicFolder, + playlist, + playlistListEntry, + share, + shareListEntry, song, - topSongsList, + user, }, + changePassword, + createInternetRadioStation, + createPlaylist, + createShare, + createUser, + deleteInternetRadioStation, + deletePlaylist, + deleteShare, + deleteUser, + getAlbum, + getAlbumInfo, + getAlbumInfo2, + getAlbumList, + getAlbumList2, + getArtist, + getArtistInfo, + getArtistInfo2, + getArtists, + getGenres, + getIndexes, + getInternetRadioStations, + getLicense, + getLyrics, + getMusicDirectory, + getMusicFolders, + getNowPlaying, + getOpenSubsonicExtensions, + getPlaylist, + getPlaylists, + getRandomSongs, + getScanStatus, + getShares, + getSimilarSongs, + getSimilarSongs2, + getSong, + getSongsByGenre, + getStarred, + getStarred2, + getTopSongs, + getUser, + getUsers, + ping, + scrobble, + search3, + setRating, + star, + startScan, + unstar, + updateInternetRadioStation, + updatePlaylist, + updateShare, + updateUser, }; From d08d3686de3b0da271cfe3be36ef948cf1356034 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 3 Dec 2023 16:46:46 -0800 Subject: [PATCH 02/23] Add logger function --- src/logger.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/logger.ts diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 000000000..a3b15f160 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,47 @@ +import console from 'console'; +import dayjs from 'dayjs'; + +const reset = '\x1b[0m'; + +const baseLog = (errorType: 'error' | 'info' | 'success' | 'warn') => { + let logString = ''; + switch (errorType) { + case 'error': + logString = '\x1b[31m[ERROR] '; + break; + case 'info': + logString = '\x1b[34m[INFO] '; + break; + case 'success': + logString = '\x1b[32m[SUCCESS] '; + break; + case 'warn': + logString = '\x1b[33m[WARNING] '; + break; + default: + logString = '\x1b[34m[INFO] '; + break; + } + + return ( + text: string, + options?: { context?: Record; throwError?: boolean; toast?: boolean }, + ): null | Error => { + const { throwError } = options || {}; + const now = dayjs().toISOString(); + console.log(`${logString}${now}${text}${JSON.stringify(options?.context)}${reset}`); + + if (!throwError) { + return null; + } + + throw new Error(text); + }; +}; + +export const fsLog = { + error: baseLog('error'), + info: baseLog('info'), + success: baseLog('success'), + warn: baseLog('warn'), +}; From 509627a0ad62251872201d3179a57e964f28d871 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 3 Dec 2023 16:47:37 -0800 Subject: [PATCH 03/23] Allow null totalRecordCount on paginated response --- src/renderer/api/types.ts | 55 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 5165c7fb1..ac75ad12d 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -124,7 +124,7 @@ export interface BasePaginatedResponse { error?: string | any; items: T; startIndex: number; - totalRecordCount: number; + totalRecordCount: number | null; } export type AuthenticationResponse = { @@ -370,7 +370,10 @@ export type AlbumListQuery = { navidrome?: Partial>; }; artistIds?: string[]; + genre?: string; limit?: number; + maxYear?: number; + minYear?: number; musicFolderId?: string; searchTerm?: string; sortBy: AlbumListSort; @@ -826,6 +829,11 @@ export type UpdatePlaylistBody = { comment?: string; genres?: Genre[]; name: string; + owner?: string; + ownerId?: string; + public?: boolean; + rules?: Record; + sync?: boolean; }; export type UpdatePlaylistArgs = { @@ -1139,3 +1147,48 @@ export type FontData = { postscriptName: string; style: string; }; + +export type ControllerEndpoint = Partial<{ + addToPlaylist: (args: AddToPlaylistArgs) => Promise; + authenticate: ( + url: string, + body: { password: string; username: string }, + ) => Promise; + clearPlaylist: () => void; + createFavorite: (args: FavoriteArgs) => Promise; + createPlaylist: (args: CreatePlaylistArgs) => Promise; + deleteFavorite: (args: FavoriteArgs) => Promise; + deletePlaylist: (args: DeletePlaylistArgs) => Promise; + getAlbumArtistDetail: ( + args: AlbumArtistDetailArgs, + ) => Promise; + getAlbumArtistList: (args: AlbumArtistListArgs) => Promise; + getAlbumDetail: (args: AlbumDetailArgs) => Promise; + getAlbumList: (args: AlbumListArgs) => Promise; + getAlbumSongList: (args: AlbumDetailArgs) => Promise; // TODO + getArtistDetail: () => void; + getArtistInfo: (args: any) => void; + getArtistList: (args: ArtistListArgs) => Promise; + getFavoritesList: () => void; + getFolderItemList: () => void; + getFolderList: () => void; + getFolderSongs: () => void; + getGenreList: (args: GenreListArgs) => Promise; + getLyrics: (args: LyricsArgs) => Promise; + getMusicFolderList: (args: MusicFolderListArgs) => Promise; + getPlaylistDetail: (args: PlaylistDetailArgs) => Promise; + getPlaylistList: (args: PlaylistListArgs) => Promise; + getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; + getRandomSongList: (args: RandomSongListArgs) => Promise; + getSongDetail: (args: SongDetailArgs) => Promise; + getSongList: (args: SongListArgs) => Promise; + getTopSongs: (args: TopSongListArgs) => Promise; + getUserList: (args: UserListArgs) => Promise; + removeFromPlaylist: ( + args: RemoveFromPlaylistArgs, + ) => Promise; + scrobble: (args: ScrobbleArgs) => Promise; + search: (args: SearchArgs) => Promise; + setRating: (args: SetRatingArgs) => Promise; + updatePlaylist: (args: UpdatePlaylistArgs) => Promise; +}>; From 3b155cc6e85314055f3745a8a0a311e19f95d4d9 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 3 Dec 2023 22:15:03 -0800 Subject: [PATCH 04/23] Remove throw from log function - Typescript cannot determine if a function throws an error - Does not work as a type guard when using ts-rest --- src/logger.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/logger.ts b/src/logger.ts index a3b15f160..9ecfe188f 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,3 @@ -import console from 'console'; import dayjs from 'dayjs'; const reset = '\x1b[0m'; @@ -23,19 +22,10 @@ const baseLog = (errorType: 'error' | 'info' | 'success' | 'warn') => { break; } - return ( - text: string, - options?: { context?: Record; throwError?: boolean; toast?: boolean }, - ): null | Error => { - const { throwError } = options || {}; + return (text: string, options?: { context?: Record; toast?: boolean }): void => { + // const { toast } = options || {}; const now = dayjs().toISOString(); console.log(`${logString}${now}${text}${JSON.stringify(options?.context)}${reset}`); - - if (!throwError) { - return null; - } - - throw new Error(text); }; }; From 8fcf5291c4ad9358db4df93d5dbec4ffe973fc6b Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 3 Dec 2023 22:15:45 -0800 Subject: [PATCH 05/23] Add first iteration of new subsonic controller --- src/renderer/api/controller.ts | 176 +---- .../api/navidrome/navidrome-controller.ts | 58 +- .../api/subsonic/subsonic-controller.ts | 704 +++++++++++------- .../api/subsonic/subsonic-normalize.ts | 23 +- src/renderer/api/subsonic/subsonic-types.ts | 32 +- 5 files changed, 533 insertions(+), 460 deletions(-) diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 196a848c1..fc3cd1d14 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -1,100 +1,38 @@ -import { useAuthStore } from '/@/renderer/store'; -import { toast } from '/@/renderer/components/toast/index'; +import { RandomSongListArgs } from './types'; +import i18n from '/@/i18n/i18n'; +import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller'; +import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller'; +import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; import type { - AlbumDetailArgs, - AlbumListArgs, - SongListArgs, - SongDetailArgs, + AddToPlaylistArgs, AlbumArtistDetailArgs, AlbumArtistListArgs, - SetRatingArgs, - GenreListArgs, + AlbumDetailArgs, + AlbumListArgs, + ArtistListArgs, + ControllerEndpoint, CreatePlaylistArgs, DeletePlaylistArgs, + FavoriteArgs, + GenreListArgs, + LyricsArgs, + MusicFolderListArgs, PlaylistDetailArgs, PlaylistListArgs, - MusicFolderListArgs, PlaylistSongListArgs, - ArtistListArgs, - UpdatePlaylistArgs, - UserListArgs, - FavoriteArgs, - TopSongListArgs, - AddToPlaylistArgs, - AddToPlaylistResponse, RemoveFromPlaylistArgs, - RemoveFromPlaylistResponse, ScrobbleArgs, - ScrobbleResponse, - AlbumArtistDetailResponse, - FavoriteResponse, - CreatePlaylistResponse, - AlbumArtistListResponse, - AlbumDetailResponse, - AlbumListResponse, - ArtistListResponse, - GenreListResponse, - MusicFolderListResponse, - PlaylistDetailResponse, - PlaylistListResponse, - RatingResponse, - SongDetailResponse, - SongListResponse, - TopSongListResponse, - UpdatePlaylistResponse, - UserListResponse, - AuthenticationResponse, SearchArgs, - SearchResponse, - LyricsArgs, - LyricsResponse, + SetRatingArgs, + SongDetailArgs, + SongListArgs, + TopSongListArgs, + UpdatePlaylistArgs, + UserListArgs, } from '/@/renderer/api/types'; +import { toast } from '/@/renderer/components/toast/index'; +import { useAuthStore } from '/@/renderer/store'; import { ServerType } from '/@/renderer/types'; -import { DeletePlaylistResponse, RandomSongListArgs } from './types'; -import { ndController } from '/@/renderer/api/navidrome/navidrome-controller'; -import { ssController } from '/@/renderer/api/subsonic/subsonic-controller'; -import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller'; -import i18n from '/@/i18n/i18n'; - -export type ControllerEndpoint = Partial<{ - addToPlaylist: (args: AddToPlaylistArgs) => Promise; - authenticate: ( - url: string, - body: { password: string; username: string }, - ) => Promise; - clearPlaylist: () => void; - createFavorite: (args: FavoriteArgs) => Promise; - createPlaylist: (args: CreatePlaylistArgs) => Promise; - deleteFavorite: (args: FavoriteArgs) => Promise; - deletePlaylist: (args: DeletePlaylistArgs) => Promise; - getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise; - getAlbumArtistList: (args: AlbumArtistListArgs) => Promise; - getAlbumDetail: (args: AlbumDetailArgs) => Promise; - getAlbumList: (args: AlbumListArgs) => Promise; - getArtistDetail: () => void; - getArtistInfo: (args: any) => void; - getArtistList: (args: ArtistListArgs) => Promise; - getFavoritesList: () => void; - getFolderItemList: () => void; - getFolderList: () => void; - getFolderSongs: () => void; - getGenreList: (args: GenreListArgs) => Promise; - getLyrics: (args: LyricsArgs) => Promise; - getMusicFolderList: (args: MusicFolderListArgs) => Promise; - getPlaylistDetail: (args: PlaylistDetailArgs) => Promise; - getPlaylistList: (args: PlaylistListArgs) => Promise; - getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; - getRandomSongList: (args: RandomSongListArgs) => Promise; - getSongDetail: (args: SongDetailArgs) => Promise; - getSongList: (args: SongListArgs) => Promise; - getTopSongs: (args: TopSongListArgs) => Promise; - getUserList: (args: UserListArgs) => Promise; - removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; - scrobble: (args: ScrobbleArgs) => Promise; - search: (args: SearchArgs) => Promise; - setRating: (args: SetRatingArgs) => Promise; - updatePlaylist: (args: UpdatePlaylistArgs) => Promise; -}>; type ApiController = { jellyfin: ControllerEndpoint; @@ -139,74 +77,8 @@ const endpoints: ApiController = { setRating: undefined, updatePlaylist: jfController.updatePlaylist, }, - navidrome: { - addToPlaylist: ndController.addToPlaylist, - authenticate: ndController.authenticate, - clearPlaylist: undefined, - createFavorite: ssController.createFavorite, - createPlaylist: ndController.createPlaylist, - deleteFavorite: ssController.removeFavorite, - deletePlaylist: ndController.deletePlaylist, - getAlbumArtistDetail: ndController.getAlbumArtistDetail, - getAlbumArtistList: ndController.getAlbumArtistList, - getAlbumDetail: ndController.getAlbumDetail, - getAlbumList: ndController.getAlbumList, - getArtistDetail: undefined, - getArtistInfo: undefined, - getArtistList: undefined, - getFavoritesList: undefined, - getFolderItemList: undefined, - getFolderList: undefined, - getFolderSongs: undefined, - getGenreList: ndController.getGenreList, - getLyrics: undefined, - getMusicFolderList: ssController.getMusicFolderList, - getPlaylistDetail: ndController.getPlaylistDetail, - getPlaylistList: ndController.getPlaylistList, - getPlaylistSongList: ndController.getPlaylistSongList, - getRandomSongList: ssController.getRandomSongList, - getSongDetail: ndController.getSongDetail, - getSongList: ndController.getSongList, - getTopSongs: ssController.getTopSongList, - getUserList: ndController.getUserList, - removeFromPlaylist: ndController.removeFromPlaylist, - scrobble: ssController.scrobble, - search: ssController.search3, - setRating: ssController.setRating, - updatePlaylist: ndController.updatePlaylist, - }, - subsonic: { - authenticate: ssController.authenticate, - clearPlaylist: undefined, - createFavorite: ssController.createFavorite, - createPlaylist: undefined, - deleteFavorite: ssController.removeFavorite, - deletePlaylist: undefined, - getAlbumArtistDetail: undefined, - getAlbumArtistList: undefined, - getAlbumDetail: undefined, - getAlbumList: undefined, - getArtistDetail: undefined, - getArtistInfo: undefined, - getArtistList: undefined, - getFavoritesList: undefined, - getFolderItemList: undefined, - getFolderList: undefined, - getFolderSongs: undefined, - getGenreList: undefined, - getLyrics: undefined, - getMusicFolderList: ssController.getMusicFolderList, - getPlaylistDetail: undefined, - getPlaylistList: undefined, - getSongDetail: undefined, - getSongList: undefined, - getTopSongs: ssController.getTopSongList, - getUserList: undefined, - scrobble: ssController.scrobble, - search: ssController.search3, - setRating: undefined, - updatePlaylist: undefined, - }, + navidrome: NavidromeController, + subsonic: SubsonicController, }; const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => { diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 486cb1bc9..abb23bb99 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -39,11 +39,13 @@ import { RemoveFromPlaylistResponse, RemoveFromPlaylistArgs, genreListSortMap, + ControllerEndpoint, } from '../types'; import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api'; import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize'; import { ndType } from '/@/renderer/api/navidrome/navidrome-types'; -import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; +import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api'; +import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; const authenticate = async ( url: string, @@ -129,7 +131,7 @@ const getAlbumArtistDetail = async ( }, }); - const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({ + const artistInfoRes = await subsonicApiClient(apiClientProps).getArtistInfo({ query: { count: 10, id: query.id, @@ -148,15 +150,16 @@ const getAlbumArtistDetail = async ( { ...res.body.data, ...(artistInfoRes.status === 200 && { - similarArtists: artistInfoRes.body.artistInfo.similarArtist, + similarArtists: artistInfoRes.body['subsonic-response'].artistInfo.similarArtist, ...(!res.body.data.largeImageUrl && { - largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl, + largeImageUrl: artistInfoRes.body['subsonic-response'].artistInfo.largeImageUrl, }), ...(!res.body.data.mediumImageUrl && { - largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl, + largeImageUrl: + artistInfoRes.body['subsonic-response'].artistInfo.mediumImageUrl, }), ...(!res.body.data.smallImageUrl && { - largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl, + largeImageUrl: artistInfoRes.body['subsonic-response'].artistInfo.smallImageUrl, }), }), }, @@ -322,7 +325,7 @@ const updatePlaylist = async (args: UpdatePlaylistArgs): Promise => { - const { apiClientProps } = args; +export const SubsonicController: ControllerEndpoint = { + addToPlaylist: async (args) => { + const { body, query, apiClientProps } = args; - const res = await ssApiClient(apiClientProps).getMusicFolderList({}); + const res = await subsonicApiClient(apiClientProps).updatePlaylist({ + query: { + playlistId: query.id, + songIdToAdd: body.songId, + }, + }); - if (res.status !== 200) { - throw new Error('Failed to get music folder list'); - } + if (res.status !== 200) { + fsLog.error('Failed to add to playlist'); + throw new Error('Failed to add to playlist'); + } - return { - items: res.body.musicFolders.musicFolder, - startIndex: 0, - totalRecordCount: res.body.musicFolders.musicFolder.length, - }; -}; + return null; + }, + authenticate: async (url, body) => { + const res = await authenticate(url, body); + return res; + }, + createFavorite: async (args) => { + const { query, apiClientProps } = args; -// export const getAlbumArtistDetail = async ( -// args: AlbumArtistDetailArgs, -// ): Promise => { -// const { server, signal, query } = args; -// const defaultParams = getDefaultParams(server); - -// const searchParams: SSAlbumArtistDetailParams = { -// id: query.id, -// ...defaultParams, -// }; - -// const data = await api -// .get('/getArtist.view', { -// prefixUrl: server?.url, -// searchParams, -// signal, -// }) -// .json(); - -// return data.artist; -// }; - -// const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { -// const { signal, server, query } = args; -// const defaultParams = getDefaultParams(server); - -// const searchParams: SSAlbumArtistListParams = { -// musicFolderId: query.musicFolderId, -// ...defaultParams, -// }; - -// const data = await api -// .get('rest/getArtists.view', { -// prefixUrl: server?.url, -// searchParams, -// signal, -// }) -// .json(); - -// const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist); - -// return { -// items: artists, -// startIndex: query.startIndex, -// totalRecordCount: null, -// }; -// }; - -// const getGenreList = async (args: GenreListArgs): Promise => { -// const { server, signal } = args; -// const defaultParams = getDefaultParams(server); - -// const data = await api -// .get('rest/getGenres.view', { -// prefixUrl: server?.url, -// searchParams: defaultParams, -// signal, -// }) -// .json(); - -// return data.genres.genre; -// }; - -// const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { -// const { server, query, signal } = args; -// const defaultParams = getDefaultParams(server); - -// const searchParams = { -// id: query.id, -// ...defaultParams, -// }; - -// const data = await api -// .get('rest/getAlbum.view', { -// prefixUrl: server?.url, -// searchParams: parseSearchParams(searchParams), -// signal, -// }) -// .json(); - -// const { song: songs, ...dataWithoutSong } = data.album; -// return { ...dataWithoutSong, songs }; -// }; - -// const getAlbumList = async (args: AlbumListArgs): Promise => { -// const { server, query, signal } = args; -// const defaultParams = getDefaultParams(server); - -// const searchParams = { -// ...defaultParams, -// }; -// const data = await api -// .get('rest/getAlbumList2.view', { -// prefixUrl: server?.url, -// searchParams: parseSearchParams(searchParams), -// signal, -// }) -// .json(); - -// return { -// items: data.albumList2.album, -// startIndex: query.startIndex, -// totalRecordCount: null, -// }; -// }; - -const createFavorite = async (args: FavoriteArgs): Promise => { - const { query, apiClientProps } = args; - - const res = await ssApiClient(apiClientProps).createFavorite({ - query: { - albumId: query.type === LibraryItem.ALBUM ? query.id : undefined, - artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined, - id: query.type === LibraryItem.SONG ? query.id : undefined, - }, - }); + const res = await subsonicApiClient(apiClientProps).star({ + query: { + albumId: query.type === LibraryItem.ALBUM ? query.id : undefined, + artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined, + id: query.type === LibraryItem.SONG ? query.id : undefined, + }, + }); - if (res.status !== 200) { - throw new Error('Failed to create favorite'); - } + if (res.status !== 200) { + fsLog.error('Failed to create favorite'); + throw new Error('Failed to create favorite'); + } - return null; -}; + return null; + }, + createPlaylist: async (args) => { + const { body, apiClientProps } = args; -const removeFavorite = async (args: FavoriteArgs): Promise => { - const { query, apiClientProps } = args; + const res = await subsonicApiClient(apiClientProps).createPlaylist({ + query: { + name: body.name, + }, + }); - const res = await ssApiClient(apiClientProps).removeFavorite({ - query: { - albumId: query.type === LibraryItem.ALBUM ? query.id : undefined, - artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined, - id: query.type === LibraryItem.SONG ? query.id : undefined, - }, - }); + if (res.status !== 200) { + fsLog.error('Failed to create playlist'); + throw new Error('Failed to create playlist'); + } - if (res.status !== 200) { - throw new Error('Failed to delete favorite'); - } + return { + id: res.body['subsonic-response'].playlist.id, + name: res.body['subsonic-response'].playlist.name, + }; + }, + deleteFavorite: async (args) => { + const { query, apiClientProps } = args; - return null; -}; + const res = await subsonicApiClient(apiClientProps).unstar({ + query: { + albumId: query.type === LibraryItem.ALBUM ? query.id : undefined, + artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined, + id: query.type === LibraryItem.SONG ? query.id : undefined, + }, + }); -const setRating = async (args: SetRatingArgs): Promise => { - const { query, apiClientProps } = args; + if (res.status !== 200) { + fsLog.error('Failed to delete favorite'); + throw new Error('Failed to delete favorite'); + } - const itemIds = query.item.map((item) => item.id); + return null; + }, + deletePlaylist: async (args) => { + const { query, apiClientProps } = args; - for (const id of itemIds) { - await ssApiClient(apiClientProps).setRating({ + const res = await subsonicApiClient(apiClientProps).deletePlaylist({ query: { - id, - rating: query.rating, + id: query.id, }, }); - } - return null; -}; + if (res.status !== 200) { + fsLog.error('Failed to delete playlist'); + throw new Error('Failed to delete playlist'); + } -const getTopSongList = async (args: TopSongListArgs): Promise => { - const { query, apiClientProps } = args; + return null; + }, + getAlbumArtistDetail: async (args) => { + const { query, apiClientProps } = args; - const res = await ssApiClient(apiClientProps).getTopSongsList({ - query: { - artist: query.artist, - count: query.limit, - }, - }); + const artistInfoRes = await subsonicApiClient(apiClientProps).getArtistInfo({ + query: { + id: query.id, + }, + }); - if (res.status !== 200) { - throw new Error('Failed to get top songs'); - } + const res = await subsonicApiClient(apiClientProps).getArtist({ + query: { + id: query.id, + }, + }); - return { - items: - res.body.topSongs?.song?.map((song) => - ssNormalize.song(song, apiClientProps.server, ''), - ) || [], - startIndex: 0, - totalRecordCount: res.body.topSongs?.song?.length || 0, - }; -}; + if (res.status !== 200) { + fsLog.error('Failed to get album artist detail'); + throw new Error('Failed to get album artist detail'); + } + + const artist = res.body['subsonic-response'].artist; + + let artistInfo; + if (artistInfoRes.status === 200) { + artistInfo = artistInfoRes.body['subsonic-response'].artistInfo; + fsLog.warn('Failed to get artist info'); + } + + return { + ...subsonicNormalize.albumArtist(artist, apiClientProps.server), + albums: artist.album.map((album) => + subsonicNormalize.album(album, apiClientProps.server), + ), + artistInfo, + }; + }, + getAlbumArtistList: async (args) => { + const { query, apiClientProps } = args; -const getArtistInfo = async ( - args: ArtistInfoArgs, -): Promise> => { - const { query, apiClientProps } = args; + const res = await subsonicApiClient(apiClientProps).getArtists({ + query: { + musicFolderId: query.musicFolderId, + }, + }); - const res = await ssApiClient(apiClientProps).getArtistInfo({ - query: { - count: query.limit, - id: query.artistId, - }, - }); + if (res.status !== 200) { + fsLog.error('Failed to get album artist list'); + throw new Error('Failed to get album artist list'); + } + + const artists = (res.body['subsonic-response'].artists?.index || []).flatMap( + (index) => index.artist, + ); + + return { + items: artists.map((artist) => + subsonicNormalize.albumArtist(artist, apiClientProps.server), + ), + startIndex: query.startIndex, + totalRecordCount: null, + }; + }, + getAlbumDetail: async (args) => { + const { query, apiClientProps } = args; - if (res.status !== 200) { - throw new Error('Failed to get artist info'); - } + const res = await subsonicApiClient(apiClientProps).getAlbum({ + query: { + id: query.id, + }, + }); - return res.body; -}; + if (res.status !== 200) { + fsLog.error('Failed to get album detail', { + context: { id: query.id }, + }); + throw new Error('Failed to get album detail'); + } -const scrobble = async (args: ScrobbleArgs): Promise => { - const { query, apiClientProps } = args; + return subsonicNormalize.album(res.body['subsonic-response'].album, apiClientProps.server); + }, + getAlbumList: async (args) => { + const { query, apiClientProps } = args; + + const sortType: Record = { + [AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM, + [AlbumListSort.ALBUM_ARTIST]: + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_ARTIST, + [AlbumListSort.PLAY_COUNT]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.FREQUENT, + [AlbumListSort.RECENTLY_ADDED]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.NEWEST, + [AlbumListSort.FAVORITED]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.STARRED, + [AlbumListSort.YEAR]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RECENT, + [AlbumListSort.NAME]: + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME, + [AlbumListSort.COMMUNITY_RATING]: undefined, + [AlbumListSort.DURATION]: undefined, + [AlbumListSort.CRITIC_RATING]: undefined, + [AlbumListSort.RATING]: undefined, + [AlbumListSort.ARTIST]: undefined, + [AlbumListSort.RECENTLY_PLAYED]: undefined, + [AlbumListSort.RELEASE_DATE]: undefined, + [AlbumListSort.SONG_COUNT]: undefined, + }; - const res = await ssApiClient(apiClientProps).scrobble({ - query: { - id: query.id, - submission: query.submission, - }, - }); + const res = await subsonicApiClient(apiClientProps).getAlbumList2({ + query: { + fromYear: query.minYear, + genre: query.genre, + musicFolderId: query.musicFolderId, + offset: query.startIndex, + size: query.limit, + toYear: query.maxYear, + type: + sortType[query.sortBy] ?? + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME, + }, + }); - if (res.status !== 200) { - throw new Error('Failed to scrobble'); - } + if (res.status !== 200) { + fsLog.error('Failed to get album list'); + throw new Error('Failed to get album list'); + } + + return { + items: res.body['subsonic-response'].albumList2.album.map((album) => + subsonicNormalize.album(album, apiClientProps.server), + ), + startIndex: query.startIndex, + totalRecordCount: null, + }; + }, + getAlbumSongList: async (args) => { + const { query, apiClientProps } = args; - return null; -}; + const res = await subsonicApiClient(apiClientProps).getAlbum({ + query: { + id: query.id, + }, + }); -const search3 = async (args: SearchArgs): Promise => { - const { query, apiClientProps } = args; + if (res.status !== 200) { + fsLog.error('Failed to get album song list'); + throw new Error('Failed to get album song list'); + } + + return { + items: res.body['subsonic-response'].album.song.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ), + startIndex: 0, + totalRecordCount: res.body['subsonic-response'].album.song.length, + }; + }, + getArtistInfo: async (args) => { + const { query, apiClientProps } = args; - const res = await ssApiClient(apiClientProps).search3({ - query: { + const res = await subsonicApiClient(apiClientProps).getArtistInfo({ + query: { + id: query.artistId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get artist info', { + context: { id: query.artistId }, + }); + throw new Error('Failed to get artist info'); + } + + return res.body['subsonic-response'].artistInfo; + }, + getGenreList: async (args) => { + const { apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getGenres({}); + + if (res.status !== 200) { + fsLog.error('Failed to get genre list'); + throw new Error('Failed to get genre list'); + } + + const genres = res.body['subsonic-response'].genres.genre.map(subsonicNormalize.genre); + + return { + items: genres, + startIndex: 0, + totalRecordCount: genres.length, + }; + }, + getMusicFolderList: async (args) => { + const { apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getMusicFolders({}); + + if (res.status !== 200) { + fsLog.error('Failed to get music folder list'); + throw new Error('Failed to get music folder list'); + } + + return { + items: res.body['subsonic-response'].musicFolders.musicFolder.map( + subsonicNormalize.musicFolder, + ), + startIndex: 0, + totalRecordCount: res.body['subsonic-response'].musicFolders.musicFolder.length, + }; + }, + getRandomSongList: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getRandomSongs({ + query: { + fromYear: query.minYear, + genre: query.genre, + musicFolderId: query.musicFolderId, + size: query.limit, + toYear: query.maxYear, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get random songs'); + throw new Error('Failed to get random songs'); + } + + return { + items: res.body['subsonic-response'].randomSongs?.song?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ), + startIndex: 0, + totalRecordCount: null, + }; + }, + getSongDetail: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getSong({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song detail'); + throw new Error('Failed to get song detail'); + } + + return subsonicNormalize.song( + res.body['subsonic-response'].song, + apiClientProps.server, + '', + ); + }, + getTopSongs: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getTopSongs({ + query: { + artist: query.artist, + count: query.limit, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get top songs', { + context: { artist: query.artist }, + }); + throw new Error('Failed to get top songs'); + } + + return { + items: + res.body['subsonic-response'].topSongs?.song?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ) || [], + startIndex: 0, + totalRecordCount: null, + }; + }, + scrobble: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).scrobble({ + query: { + id: query.id, + submission: query.submission, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to scrobble', { + context: { + id: query.id, + }, + }); + throw new Error('Failed to scrobble'); + } + + return null; + }, + search: async (args) => { + const { query, apiClientProps } = args; + + const searchQuery = { albumCount: query.albumLimit, albumOffset: query.albumStartIndex, artistCount: query.albumArtistLimit, @@ -322,61 +465,64 @@ const search3 = async (args: SearchArgs): Promise => { query: query.query, songCount: query.songLimit, songOffset: query.songStartIndex, - }, - }); + }; - if (res.status !== 200) { - throw new Error('Failed to search'); - } + const res = await subsonicApiClient(apiClientProps).search3({ + query: searchQuery, + }); - return { - albumArtists: res.body.searchResult3?.artist?.map((artist) => - ssNormalize.albumArtist(artist, apiClientProps.server), - ), - albums: res.body.searchResult3?.album?.map((album) => - ssNormalize.album(album, apiClientProps.server), - ), - songs: res.body.searchResult3?.song?.map((song) => - ssNormalize.song(song, apiClientProps.server, ''), - ), - }; -}; + if (res.status !== 200) { + fsLog.error('Failed to search', { + context: searchQuery, + }); + throw new Error('Failed to search'); + } + + return { + albumArtists: res.body['subsonic-response'].searchResult3?.artist?.map((artist) => + subsonicNormalize.albumArtist(artist, apiClientProps.server), + ), + albums: res.body['subsonic-response'].searchResult3?.album?.map((album) => + subsonicNormalize.album(album, apiClientProps.server), + ), + songs: res.body['subsonic-response'].searchResult3?.song?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ), + }; + }, + setRating: async (args) => { + const { query, apiClientProps } = args; -const getRandomSongList = async (args: RandomSongListArgs): Promise => { - const { query, apiClientProps } = args; + const itemIds = query.item.map((item) => item.id); - const res = await ssApiClient(apiClientProps).getRandomSongList({ - query: { - fromYear: query.minYear, - genre: query.genre, - musicFolderId: query.musicFolderId, - size: query.limit, - toYear: query.maxYear, - }, - }); + for (const id of itemIds) { + await subsonicApiClient(apiClientProps).setRating({ + query: { + id, + rating: query.rating, + }, + }); + } - if (res.status !== 200) { - throw new Error('Failed to get random songs'); - } + return null; + }, + updatePlaylist: async (args) => { + const { body, query, apiClientProps } = args; - return { - items: res.body.randomSongs?.song?.map((song) => - ssNormalize.song(song, apiClientProps.server, ''), - ), - startIndex: 0, - totalRecordCount: res.body.randomSongs?.song?.length || 0, - }; -}; + const res = await subsonicApiClient(apiClientProps).updatePlaylist({ + query: { + comment: body.comment, + name: body.name, + playlistId: query.id, + public: body.public, + }, + }); -export const ssController = { - authenticate, - createFavorite, - getArtistInfo, - getMusicFolderList, - getRandomSongList, - getTopSongList, - removeFavorite, - scrobble, - search3, - setRating, + if (res.status !== 200) { + fsLog.error('Failed to update playlist'); + throw new Error('Failed to update playlist'); + } + + return null; + }, }; diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index 2328515a5..9c3b8e381 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -1,7 +1,14 @@ import { nanoid } from 'nanoid'; import { z } from 'zod'; import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; -import { QueueSong, LibraryItem, AlbumArtist, Album, Genre } from '/@/renderer/api/types'; +import { + QueueSong, + LibraryItem, + AlbumArtist, + Album, + Genre, + MusicFolder, +} from '/@/renderer/api/types'; import { ServerListItem, ServerType } from '/@/renderer/types'; const getCoverArtUrl = (args: { @@ -105,7 +112,9 @@ const normalizeSong = ( }; const normalizeAlbumArtist = ( - item: z.infer, + item: + | z.infer + | z.infer, server: ServerListItem | null, ): AlbumArtist => { const imageUrl = @@ -202,9 +211,19 @@ const normalizeGenre = (item: z.infer): Gen }; }; +const normalizeMusicFolder = ( + item: z.infer, +): MusicFolder => { + return { + id: item.id, + name: item.name, + }; +}; + export const subsonicNormalize = { album: normalizeAlbum, albumArtist: normalizeAlbumArtist, genre: normalizeGenre, + musicFolder: normalizeMusicFolder, song: normalizeSong, }; diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index 118622be4..737477509 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -156,23 +156,21 @@ const playlistListEntry = playlist.omit({ }); const artistInfo = z.object({ - artistInfo: z.object({ - biography: z.string().optional(), - largeImageUrl: z.string().optional(), - lastFmUrl: z.string().optional(), - mediumImageUrl: z.string().optional(), - musicBrainzId: z.string().optional(), - similarArtist: z.array( - z.object({ - albumCount: z.string(), - artistImageUrl: z.string().optional(), - coverArt: z.string().optional(), - id: z.string(), - name: z.string(), - }), - ), - smallImageUrl: z.string().optional(), - }), + biography: z.string().optional(), + largeImageUrl: z.string().optional(), + lastFmUrl: z.string().optional(), + mediumImageUrl: z.string().optional(), + musicBrainzId: z.string().optional(), + similarArtist: z.array( + z.object({ + albumCount: z.string(), + artistImageUrl: z.string().optional(), + coverArt: z.string().optional(), + id: z.string(), + name: z.string(), + }), + ), + smallImageUrl: z.string().optional(), }); const albumInfo = z.object({ From f8d109fce49795e2d99158184afaa3b7bbb95f89 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 3 Dec 2023 22:16:13 -0800 Subject: [PATCH 06/23] Set search query to required --- src/renderer/api/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index ac75ad12d..553a46cd2 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1022,7 +1022,7 @@ export type SearchQuery = { albumLimit?: number; albumStartIndex?: number; musicFolderId?: string; - query?: string; + query: string; songLimit?: number; songStartIndex?: number; }; From 33b522a2f3d21f9a204a74bd788963c5879813fa Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 3 Dec 2023 22:17:01 -0800 Subject: [PATCH 07/23] Fix expected controller responses --- src/renderer/api/types.ts | 60 ++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 553a46cd2..6b62f033e 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1149,46 +1149,42 @@ export type FontData = { }; export type ControllerEndpoint = Partial<{ - addToPlaylist: (args: AddToPlaylistArgs) => Promise; + addToPlaylist: (args: AddToPlaylistArgs) => Promise; authenticate: ( url: string, body: { password: string; username: string }, - ) => Promise; + ) => 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; - getAlbumSongList: (args: AlbumDetailArgs) => Promise; // TODO + 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; + getAlbumSongList: (args: AlbumDetailArgs) => Promise; // TODO getArtistDetail: () => void; getArtistInfo: (args: any) => void; - getArtistList: (args: ArtistListArgs) => Promise; + getArtistList: (args: ArtistListArgs) => Promise; getFavoritesList: () => void; getFolderItemList: () => void; getFolderList: () => void; getFolderSongs: () => void; - getGenreList: (args: GenreListArgs) => Promise; - getLyrics: (args: LyricsArgs) => Promise; - getMusicFolderList: (args: MusicFolderListArgs) => Promise; - getPlaylistDetail: (args: PlaylistDetailArgs) => Promise; - getPlaylistList: (args: PlaylistListArgs) => Promise; - getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; - getRandomSongList: (args: RandomSongListArgs) => Promise; - getSongDetail: (args: SongDetailArgs) => Promise; - getSongList: (args: SongListArgs) => Promise; - getTopSongs: (args: TopSongListArgs) => Promise; - getUserList: (args: UserListArgs) => Promise; - removeFromPlaylist: ( - args: RemoveFromPlaylistArgs, - ) => Promise; - scrobble: (args: ScrobbleArgs) => Promise; - search: (args: SearchArgs) => Promise; - setRating: (args: SetRatingArgs) => Promise; - updatePlaylist: (args: UpdatePlaylistArgs) => Promise; + getGenreList: (args: GenreListArgs) => Promise; + getLyrics: (args: LyricsArgs) => Promise; + getMusicFolderList: (args: MusicFolderListArgs) => Promise; + getPlaylistDetail: (args: PlaylistDetailArgs) => Promise; + getPlaylistList: (args: PlaylistListArgs) => Promise; + getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; + getRandomSongList: (args: RandomSongListArgs) => Promise; + getSongDetail: (args: SongDetailArgs) => Promise; + getSongList: (args: SongListArgs) => Promise; + getTopSongs: (args: TopSongListArgs) => Promise; + getUserList: (args: UserListArgs) => Promise; + removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; + scrobble: (args: ScrobbleArgs) => Promise; + search: (args: SearchArgs) => Promise; + setRating: (args: SetRatingArgs) => Promise; + updatePlaylist: (args: UpdatePlaylistArgs) => Promise; }>; From b7bbba928dd22231b7c2520576d537f91a3ab0ea Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 5 Dec 2023 18:25:41 -0800 Subject: [PATCH 08/23] Update log format --- src/logger.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/logger.ts b/src/logger.ts index 9ecfe188f..162a9ffad 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -25,7 +25,11 @@ const baseLog = (errorType: 'error' | 'info' | 'success' | 'warn') => { return (text: string, options?: { context?: Record; toast?: boolean }): void => { // const { toast } = options || {}; const now = dayjs().toISOString(); - console.log(`${logString}${now}${text}${JSON.stringify(options?.context)}${reset}`); + console.log( + `${logString}${now}: ${text} | ${ + options?.context && JSON.stringify(options.context) + }${reset}`, + ); }; }; From 2ecafea75935e368f781ac771e5dba0d3e73b4fc Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 5 Dec 2023 18:29:07 -0800 Subject: [PATCH 09/23] Fix album count translation string --- src/i18n/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3997bd640..9dc7b0de6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -552,7 +552,7 @@ "column": { "album": "album", "albumArtist": "album artist", - "albumCount": "$t(entity.album_other)", + "albumCount": "$t(entity.album_one)", "artist": "$t(entity.artist_one)", "biography": "biography", "bitrate": "bitrate", From b2f14d7369956e831bfda1b062614a9aab65b0ff Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 5 Dec 2023 18:32:44 -0800 Subject: [PATCH 10/23] Support entity list pages for subsonic --- src/renderer/api/controller.ts | 40 ++ .../api/jellyfin/jellyfin-controller.ts | 2 - .../api/navidrome/navidrome-normalize.ts | 6 +- src/renderer/api/query-keys.ts | 55 +- src/renderer/api/subsonic/subsonic-api.ts | 19 +- .../api/subsonic/subsonic-controller.ts | 492 +++++++++++++++++- .../api/subsonic/subsonic-normalize.ts | 42 +- src/renderer/api/subsonic/subsonic-types.ts | 2 +- src/renderer/api/types.ts | 13 + .../virtual-table/hooks/use-virtual-table.ts | 15 + .../components/album-list-header-filters.tsx | 50 +- .../albums/components/album-list-header.tsx | 2 + .../albums/queries/album-list-count-query.ts | 41 ++ .../albums/routes/album-list-route.tsx | 11 +- .../album-artist-list-header-filters.tsx | 22 + .../components/album-artist-list-header.tsx | 1 + .../queries/album-artist-list-count-query.ts | 38 ++ .../routes/album-artist-list-route.tsx | 11 +- .../components/genre-list-header-filters.tsx | 25 +- .../genres/components/genre-list-header.tsx | 2 + .../playlist-list-header-filters.tsx | 32 ++ .../components/playlist-list-header.tsx | 1 + .../components/song-list-header-filters.tsx | 15 +- .../songs/components/song-list-header.tsx | 2 + .../songs/queries/song-list-count-query.ts | 39 ++ .../features/songs/routes/song-list-route.tsx | 10 +- src/renderer/hooks/use-list-filter-refresh.ts | 11 +- 27 files changed, 944 insertions(+), 55 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 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 fc3cd1d14..dddd79bb4 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -131,6 +131,15 @@ const getAlbumList = async (args: AlbumListArgs) => { )?.(args); }; +const getAlbumListCount = async (args: AlbumListArgs) => { + return ( + apiController( + 'getAlbumListCount', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getAlbumListCount'] + )?.(args); +}; + const getAlbumDetail = async (args: AlbumDetailArgs) => { return ( apiController( @@ -149,6 +158,15 @@ const getSongList = async (args: SongListArgs) => { )?.(args); }; +const getSongListCount = async (args: SongListArgs) => { + return ( + apiController( + 'getSongListCount', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getSongListCount'] + )?.(args); +}; + const getSongDetail = async (args: SongDetailArgs) => { return ( apiController( @@ -194,6 +212,15 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs) => { )?.(args); }; +const getAlbumArtistListCount = async (args: AlbumArtistListArgs) => { + return ( + apiController( + 'getAlbumArtistListCount', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getAlbumArtistListCount'] + )?.(args); +}; + const getArtistList = async (args: ArtistListArgs) => { return ( apiController( @@ -212,6 +239,15 @@ const getPlaylistList = async (args: PlaylistListArgs) => { )?.(args); }; +const getPlaylistListCount = async (args: PlaylistListArgs) => { + return ( + apiController( + 'getPlaylistListCount', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getPlaylistListCount'] + )?.(args); +}; + const createPlaylist = async (args: CreatePlaylistArgs) => { return ( apiController( @@ -362,18 +398,22 @@ export const controller = { deletePlaylist, getAlbumArtistDetail, getAlbumArtistList, + getAlbumArtistListCount, getAlbumDetail, getAlbumList, + getAlbumListCount, getArtistList, getGenreList, getLyrics, getMusicFolderList, getPlaylistDetail, getPlaylistList, + getPlaylistListCount, getPlaylistSongList, getRandomSongList, getSongDetail, getSongList, + getSongListCount, getTopSongList, getUserList, removeFromPlaylist, diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index dd53aab6d..4112fd8a3 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -480,7 +480,6 @@ const removeFromPlaylist = async ( const { query, apiClientProps } = args; const res = await jfApiClient(apiClientProps).removeFromPlaylist({ - body: null, params: { id: query.id, }, @@ -648,7 +647,6 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { const { query, apiClientProps } = args; const res = await jfApiClient(apiClientProps).deletePlaylist({ - body: null, params: { id: query.id, }, diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts index 4aafb14e0..d03c0d234 100644 --- a/src/renderer/api/navidrome/navidrome-normalize.ts +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -11,8 +11,8 @@ import { import { ServerListItem, ServerType } from '/@/renderer/types'; import z from 'zod'; import { ndType } from './navidrome-types'; -import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; import { NDGenre } from '/@/renderer/api/navidrome.types'; +import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; const getImageUrl = (args: { url: string | null }) => { const { url } = args; @@ -186,7 +186,9 @@ const normalizeAlbum = ( const normalizeAlbumArtist = ( item: z.infer & { - similarArtists?: z.infer['artistInfo']['similarArtist']; + similarArtists?: z.infer< + typeof SubsonicApi.getArtistInfo2.response + >['subsonic-response']['artistInfo2']['similarArtist']; }, server: ServerListItem | null, ): AlbumArtist => { diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 30433e461..fa6b96b0b 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -49,6 +49,19 @@ export const queryKeys: Record< Record QueryFunctionContext['queryKey']> > = { albumArtists: { + count: (serverId: string, query?: AlbumArtistListQuery) => { + const { pagination, filter } = splitPaginatedQuery(query); + + if (query && pagination) { + return [serverId, 'albumArtists', 'count', filter, pagination] as const; + } + + if (query) { + return [serverId, 'albumArtists', 'count', filter] as const; + } + + return [serverId, 'albumArtists', 'count'] as const; + }, detail: (serverId: string, query?: AlbumArtistDetailQuery) => { if (query) return [serverId, 'albumArtists', 'detail', query] as const; return [serverId, 'albumArtists', 'detail'] as const; @@ -72,23 +85,40 @@ export const queryKeys: Record< }, }, albums: { - detail: (serverId: string, query?: AlbumDetailQuery) => - [serverId, 'albums', 'detail', query] as const, - list: (serverId: string, query?: AlbumListQuery, artistId?: string) => { + count: (serverId: string, query?: AlbumListQuery, artistId?: string) => { const { pagination, filter } = splitPaginatedQuery(query); if (query && pagination && artistId) { - return [serverId, 'albums', 'list', artistId, filter, pagination] as const; + return [serverId, 'albums', 'count', artistId, filter, pagination] as const; } if (query && pagination) { - return [serverId, 'albums', 'list', filter, pagination] as const; + return [serverId, 'albums', 'count', filter, pagination] as const; } if (query && artistId) { - return [serverId, 'albums', 'list', artistId, filter] as const; + return [serverId, 'albums', 'count', artistId, filter] as const; + } + + if (query) { + return [serverId, 'albums', 'count', filter] as const; } + return [serverId, 'albums', 'count'] as const; + }, + detail: (serverId: string, query?: AlbumDetailQuery) => + [serverId, 'albums', 'detail', query] as const, + list: ( + serverId: string, + query?: { + artistIds?: string[]; + maxYear?: number; + minYear?: number; + searchTerm?: string; + }, + ) => { + const { filter } = splitPaginatedQuery(query); + if (query) { return [serverId, 'albums', 'list', filter] as const; } @@ -207,6 +237,19 @@ export const queryKeys: Record< root: (serverId: string) => [serverId] as const, }, songs: { + count: (serverId: string, query?: AlbumArtistListQuery) => { + const { pagination, filter } = splitPaginatedQuery(query); + + if (query && pagination) { + return [serverId, 'songs', 'count', filter, pagination] as const; + } + + if (query) { + return [serverId, 'songs', 'count', filter] as const; + } + + return [serverId, 'songs', 'count'] as const; + }, detail: (serverId: string, query?: SongDetailQuery) => { if (query) return [serverId, 'songs', 'detail', query] as const; return [serverId, 'songs', 'detail'] as const; diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index fa5ad2c18..4d2eb1036 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -435,16 +435,21 @@ axiosClient.interceptors.response.use( (response) => { const data = response.data; - if (data['subsonic-response'].status !== 'ok') { + // Ping endpoint returns a string + if (typeof data === 'string') { + return response; + } + + if (data['subsonic-response']?.status !== 'ok') { // Suppress code related to non-linked lastfm or spotify from Navidrome - if (data['subsonic-response'].error.code !== 0) { + if (data['subsonic-response']?.error.code !== 0) { toast.error({ - message: data['subsonic-response'].error.message, + message: data['subsonic-response']?.error.message, title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string, }); } - return Promise.reject(data['subsonic-response'].error); + return Promise.reject(data['subsonic-response']?.error); } return response; @@ -513,9 +518,9 @@ export const subsonicApiClient = (args: { }); return { - body: result.data, - headers: result.headers as any, - status: result.status, + body: result?.data, + headers: result?.headers as any, + status: result?.status, }; } catch (e: Error | AxiosError | any) { if (isAxiosError(e)) { diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index ab82df47d..a7f1a10ab 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1,15 +1,20 @@ +import orderBy from 'lodash/orderBy'; +import filter from 'lodash/filter'; import md5 from 'md5'; +import { fsLog } from '/@/logger'; import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api'; import { subsonicNormalize } from '/@/renderer/api/subsonic/subsonic-normalize'; import { AlbumListSortType, SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; import { + AlbumArtistListSort, AlbumListSort, AuthenticationResponse, ControllerEndpoint, + GenreListSort, LibraryItem, + PlaylistListSort, } from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; -import { fsLog } from '/@/logger'; const authenticate = async ( url: string, @@ -184,7 +189,7 @@ export const SubsonicController: ControllerEndpoint = { } return { - ...subsonicNormalize.albumArtist(artist, apiClientProps.server), + ...subsonicNormalize.albumArtist(artist, apiClientProps.server, 300), albums: artist.album.map((album) => subsonicNormalize.album(album, apiClientProps.server), ), @@ -193,6 +198,7 @@ export const SubsonicController: ControllerEndpoint = { }, getAlbumArtistList: async (args) => { const { query, apiClientProps } = args; + const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; const res = await subsonicApiClient(apiClientProps).getArtists({ query: { @@ -209,14 +215,79 @@ export const SubsonicController: ControllerEndpoint = { (index) => index.artist, ); + let results = artists; + let totalRecordCount = artists.length; + + if (query.searchTerm) { + const searchResults = filter(results, (artist) => { + return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + totalRecordCount = searchResults.length; + } + + switch (query.sortBy) { + case AlbumArtistListSort.ALBUM_COUNT: + results = orderBy( + artists, + ['albumCount', (v) => v.name.toLowerCase()], + [sortOrder, 'asc'], + ); + break; + case AlbumArtistListSort.NAME: + results = orderBy(artists, [(v) => v.name.toLowerCase()], [sortOrder]); + break; + case AlbumArtistListSort.FAVORITED: + results = orderBy(artists, ['starred'], [sortOrder]); + break; + case AlbumArtistListSort.RATING: + results = orderBy(artists, ['userRating'], [sortOrder]); + break; + default: + break; + } + return { - items: artists.map((artist) => + items: results.map((artist) => subsonicNormalize.albumArtist(artist, apiClientProps.server), ), startIndex: query.startIndex, - totalRecordCount: null, + totalRecordCount, }; }, + getAlbumArtistListCount: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getArtists({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album artist list count'); + throw new Error('Failed to get album artist list count'); + } + + const artists = (res.body['subsonic-response'].artists?.index || []).flatMap( + (index) => index.artist, + ); + + let results = artists; + let totalRecordCount = artists.length; + + if (query.searchTerm) { + const searchResults = filter(results, (artist) => { + return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + totalRecordCount = searchResults.length; + } + + return totalRecordCount; + }, getAlbumDetail: async (args) => { const { query, apiClientProps } = args; @@ -285,6 +356,73 @@ export const SubsonicController: ControllerEndpoint = { totalRecordCount: null, }; }, + getAlbumListCount: async (args) => { + const { query, apiClientProps } = args; + + const sortType: Record = { + [AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM, + [AlbumListSort.ALBUM_ARTIST]: + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_ARTIST, + [AlbumListSort.PLAY_COUNT]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.FREQUENT, + [AlbumListSort.RECENTLY_ADDED]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.NEWEST, + [AlbumListSort.FAVORITED]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.STARRED, + [AlbumListSort.YEAR]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RECENT, + [AlbumListSort.NAME]: + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME, + [AlbumListSort.COMMUNITY_RATING]: undefined, + [AlbumListSort.DURATION]: undefined, + [AlbumListSort.CRITIC_RATING]: undefined, + [AlbumListSort.RATING]: undefined, + [AlbumListSort.ARTIST]: undefined, + [AlbumListSort.RECENTLY_PLAYED]: undefined, + [AlbumListSort.RELEASE_DATE]: undefined, + [AlbumListSort.SONG_COUNT]: undefined, + }; + + let fetchNextPage = true; + let startIndex = 0; + let totalRecordCount = 0; + + while (fetchNextPage) { + const res = await subsonicApiClient(apiClientProps).getAlbumList2({ + query: { + fromYear: query.minYear, + genre: query.genre, + musicFolderId: query.musicFolderId, + offset: startIndex, + size: 500, + toYear: query.maxYear, + type: + sortType[query.sortBy] ?? + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME, + }, + }); + + const headers = res.headers; + + // Navidrome returns the total count in the header + if (headers.get('x-total-count')) { + fetchNextPage = false; + totalRecordCount = Number(headers.get('x-total-count')); + break; + } + + if (res.status !== 200) { + fsLog.error('Failed to get album list count'); + throw new Error('Failed to get album list count'); + } + + const albumCount = res.body['subsonic-response'].albumList2.album.length; + + totalRecordCount += albumCount; + startIndex += albumCount; + + // The max limit size for Subsonic is 500 + fetchNextPage = albumCount === 500; + } + + return totalRecordCount; + }, getAlbumSongList: async (args) => { const { query, apiClientProps } = args; @@ -326,7 +464,8 @@ export const SubsonicController: ControllerEndpoint = { return res.body['subsonic-response'].artistInfo; }, getGenreList: async (args) => { - const { apiClientProps } = args; + const { query, apiClientProps } = args; + const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; const res = await subsonicApiClient(apiClientProps).getGenres({}); @@ -335,7 +474,31 @@ export const SubsonicController: ControllerEndpoint = { throw new Error('Failed to get genre list'); } - const genres = res.body['subsonic-response'].genres.genre.map(subsonicNormalize.genre); + let results = res.body['subsonic-response'].genres.genre; + + if (query.searchTerm) { + const searchResults = filter(results, (genre) => + genre.value.toLowerCase().includes(query.searchTerm!.toLowerCase()), + ); + + results = searchResults; + } + + switch (query.sortBy) { + case GenreListSort.NAME: + results = orderBy(results, [(v) => v.value.toLowerCase()], [sortOrder]); + break; + case GenreListSort.ALBUM_COUNT: + results = orderBy(results, ['albumCount'], [sortOrder]); + break; + case GenreListSort.SONG_COUNT: + results = orderBy(results, ['songCount'], [sortOrder]); + break; + default: + break; + } + + const genres = results.map(subsonicNormalize.genre); return { items: genres, @@ -361,6 +524,70 @@ export const SubsonicController: ControllerEndpoint = { totalRecordCount: res.body['subsonic-response'].musicFolders.musicFolder.length, }; }, + getPlaylistList: async (args) => { + const { query, apiClientProps } = args; + const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; + + const res = await subsonicApiClient(apiClientProps).getPlaylists({}); + + if (res.status !== 200) { + fsLog.error('Failed to get playlist list'); + throw new Error('Failed to get playlist list'); + } + + let results = res.body['subsonic-response'].playlists.playlist; + + if (query.searchTerm) { + const searchResults = filter(results, (playlist) => { + return playlist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + } + + switch (query.sortBy) { + case PlaylistListSort.DURATION: + results = orderBy(results, ['duration'], [sortOrder]); + break; + case PlaylistListSort.NAME: + results = orderBy(results, [(v) => v.name?.toLowerCase()], [sortOrder]); + break; + case PlaylistListSort.OWNER: + results = orderBy(results, [(v) => v.owner?.toLowerCase()], [sortOrder]); + break; + case PlaylistListSort.PUBLIC: + results = orderBy(results, ['public'], [sortOrder]); + break; + case PlaylistListSort.SONG_COUNT: + results = orderBy(results, ['songCount'], [sortOrder]); + break; + case PlaylistListSort.UPDATED_AT: + results = orderBy(results, ['changed'], [sortOrder]); + break; + default: + break; + } + + return { + items: results.map((playlist) => + subsonicNormalize.playlist(playlist, apiClientProps.server), + ), + startIndex: 0, + totalRecordCount: res.body['subsonic-response'].playlists.playlist.length, + }; + }, + getPlaylistListCount: async (args) => { + const { apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getPlaylists({}); + + if (res.status !== 200) { + fsLog.error('Failed to get playlist list count'); + throw new Error('Failed to get playlist list count'); + } + + return res.body['subsonic-response'].playlists.playlist.length; + }, getRandomSongList: async (args) => { const { query, apiClientProps } = args; @@ -407,6 +634,259 @@ export const SubsonicController: ControllerEndpoint = { '', ); }, + getSongList: async (args) => { + const { query, apiClientProps } = args; + + const fromAlbumPromises = []; + const artistDetailPromises = []; + let results: any[] = []; + + if (query.genreId) { + const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ + query: { + count: query.limit, + genre: query.genreId, + musicFolderId: query.musicFolderId, + offset: query.startIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list'); + throw new Error('Failed to get song list'); + } + + return { + items: res.body['subsonic-response'].songsByGenre.song.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ), + startIndex: 0, + totalRecordCount: null, + }; + } + + if (query.albumIds || query.artistIds) { + if (query.albumIds) { + for (const albumId of query.albumIds) { + fromAlbumPromises.push( + subsonicApiClient(apiClientProps).getAlbum({ + query: { + id: albumId, + }, + }), + ); + } + } + + if (query.artistIds) { + for (const artistId of query.artistIds) { + artistDetailPromises.push( + subsonicApiClient(apiClientProps).getArtist({ + query: { + id: artistId, + }, + }), + ); + } + + const artistResult = await Promise.all(artistDetailPromises); + + const albums = artistResult.flatMap((artist) => { + if (artist.status !== 200) { + fsLog.warn('Failed to get artist detail', { context: { artist } }); + return []; + } + + return artist.body['subsonic-response'].artist.album; + }); + + const albumIds = albums.map((album) => album.id); + + for (const albumId of albumIds) { + fromAlbumPromises.push( + subsonicApiClient(apiClientProps).getAlbum({ + query: { + id: albumId, + }, + }), + ); + } + } + + if (fromAlbumPromises) { + const albumsResult = await Promise.all(fromAlbumPromises); + + results = albumsResult.flatMap((album) => { + if (album.status !== 200) { + fsLog.warn('Failed to get album detail', { context: { album } }); + return []; + } + + return album.body['subsonic-response'].album.song; + }); + } + + return { + items: results.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ), + startIndex: 0, + totalRecordCount: results.length, + }; + } + + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: query.limit, + songOffset: query.startIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list'); + throw new Error('Failed to get song list'); + } + + return { + items: + res.body['subsonic-response'].searchResult3?.song?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ) || [], + startIndex: 0, + totalRecordCount: null, + }; + }, + getSongListCount: async (args) => { + const { query, apiClientProps } = args; + + let fetchNextPage = true; + let startIndex = 0; + + let fetchNextSection = true; + let sectionIndex = 0; + + if (query.genreId) { + let totalRecordCount = 0; + while (fetchNextSection) { + const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ + query: { + count: 1, + genre: query.genreId, + musicFolderId: query.musicFolderId, + offset: sectionIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list count'); + throw new Error('Failed to get song list count'); + } + + const numberOfResults = + res.body['subsonic-response'].songsByGenre.song?.length || 0; + + if (numberOfResults !== 1) { + fetchNextSection = false; + startIndex = sectionIndex === 0 ? 0 : sectionIndex - 5000; + break; + } else { + sectionIndex += 5000; + } + } + + while (fetchNextPage) { + const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ + query: { + count: 500, + genre: query.genreId, + musicFolderId: query.musicFolderId, + offset: startIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list count'); + throw new Error('Failed to get song list count'); + } + + const numberOfResults = + res.body['subsonic-response'].songsByGenre.song?.length || 0; + + totalRecordCount = startIndex + numberOfResults; + startIndex += numberOfResults; + + fetchNextPage = numberOfResults === 500; + } + + return totalRecordCount; + } + + let totalRecordCount = 0; + + while (fetchNextSection) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 1, + songOffset: sectionIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list count'); + throw new Error('Failed to get song list count'); + } + + const numberOfResults = res.body['subsonic-response'].searchResult3.song?.length || 0; + + // Check each batch of 5000 songs to check for data + sectionIndex += 5000; + fetchNextSection = numberOfResults === 1; + + if (!fetchNextSection) { + // fetchNextBlock will be false on the next loop so we need to subtract 5000 * 2 + startIndex = sectionIndex - 10000; + } + } + + while (fetchNextPage) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 500, + songOffset: startIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list count'); + throw new Error('Failed to get song list count'); + } + + const numberOfResults = res.body['subsonic-response'].searchResult3.song?.length || 0; + + totalRecordCount = startIndex + numberOfResults; + startIndex += numberOfResults; + + // The max limit size for Subsonic is 500 + fetchNextPage = numberOfResults === 500; + } + + return totalRecordCount; + }, getTopSongs: async (args) => { const { query, apiClientProps } = args; diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index 9c3b8e381..ace3974ac 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -8,6 +8,7 @@ import { Album, Genre, MusicFolder, + Playlist, } from '/@/renderer/api/types'; import { ServerListItem, ServerType } from '/@/renderer/types'; @@ -116,13 +117,14 @@ const normalizeAlbumArtist = ( | 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 { @@ -167,7 +169,7 @@ const normalizeAlbum = ( artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [], backdropImageUrl: null, createdAt: item.created, - duration: item.duration, + duration: item.duration * 1000, genres: item.genre ? [ { @@ -192,7 +194,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, @@ -220,10 +225,41 @@ const normalizeMusicFolder = ( }; }; +const normalizePlaylist = ( + item: + | z.infer + | z.infer, + server: ServerListItem | null, +): Playlist => { + return { + description: item.comment || null, + duration: item.duration, + genres: [], + id: item.id, + imagePlaceholderUrl: null, + imageUrl: getCoverArtUrl({ + baseUrl: server?.url, + coverArtId: item.coverArt, + credential: server?.credential, + size: 300, + }), + itemType: LibraryItem.PLAYLIST, + name: item.name, + owner: item.owner, + ownerId: item.owner, + public: item.public, + serverId: server?.id || 'unknown', + serverType: ServerType.SUBSONIC, + size: null, + songCount: item.songCount, + }; +}; + export const subsonicNormalize = { album: normalizeAlbum, albumArtist: normalizeAlbumArtist, genre: normalizeGenre, musicFolder: normalizeMusicFolder, + playlist: normalizePlaylist, song: normalizeSong, }; diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index 737477509..c8e825b1b 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -582,7 +582,7 @@ const search3 = { artistCount: z.number().optional(), artistOffset: z.number().optional(), musicFolderId: z.string().optional(), - query: z.string(), + query: z.string().or(z.literal('""')), songCount: z.number().optional(), songOffset: z.number().optional(), }), diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 6b62f033e..153bb928b 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -306,7 +306,9 @@ export type GenreListResponse = BasePaginatedResponse | null | undefine export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs; export enum GenreListSort { + ALBUM_COUNT = 'albumCount', NAME = 'name', + SONG_COUNT = 'songCount', } export type GenreListQuery = { @@ -330,10 +332,14 @@ type GenreListSortMap = { export const genreListSortMap: GenreListSortMap = { jellyfin: { + albumCount: undefined, name: JFGenreListSort.NAME, + songCount: undefined, }, navidrome: { + albumCount: undefined, name: NDGenreListSort.NAME, + songCount: undefined, }, subsonic: { name: undefined, @@ -484,8 +490,11 @@ export type SongListQuery = { }; albumIds?: string[]; artistIds?: string[]; + genreId?: string; imageSize?: number; limit?: number; + maxYear?: number; + minYear?: number; musicFolderId?: string; searchTerm?: string; sortBy: SongListSort; @@ -1161,8 +1170,10 @@ export type ControllerEndpoint = Partial<{ deletePlaylist: (args: DeletePlaylistArgs) => Promise; getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise; getAlbumArtistList: (args: AlbumArtistListArgs) => Promise; + getAlbumArtistListCount: (args: AlbumArtistListArgs) => Promise; getAlbumDetail: (args: AlbumDetailArgs) => Promise; getAlbumList: (args: AlbumListArgs) => Promise; + getAlbumListCount: (args: AlbumListArgs) => Promise; getAlbumSongList: (args: AlbumDetailArgs) => Promise; // TODO getArtistDetail: () => void; getArtistInfo: (args: any) => void; @@ -1176,10 +1187,12 @@ export type ControllerEndpoint = Partial<{ getMusicFolderList: (args: MusicFolderListArgs) => Promise; getPlaylistDetail: (args: PlaylistDetailArgs) => Promise; getPlaylistList: (args: PlaylistListArgs) => Promise; + getPlaylistListCount: (args: PlaylistListArgs) => Promise; getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; getRandomSongList: (args: RandomSongListArgs) => Promise; getSongDetail: (args: SongDetailArgs) => Promise; getSongList: (args: SongListArgs) => Promise; + getSongListCount: (args: SongListArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; getUserList: (args: UserListArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; diff --git a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts index 1e7221cd1..a99cdb295 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -182,6 +182,20 @@ export const useVirtualTable = ({ return; } + if (results.totalRecordCount === null) { + const totalRecordCount: number | undefined = itemCount; + const hasMoreRows = results?.items?.length === properties.filter.limit; + const lastRowIndex = hasMoreRows + ? undefined + : properties.filter.offset + results.items.length; + + params.successCallback( + results?.items || [], + totalRecordCount || lastRowIndex, + ); + return; + } + params.successCallback(results?.items || [], results?.totalRecordCount || 0); }, rowCount: undefined, @@ -198,6 +212,7 @@ export const useVirtualTable = ({ queryClient, isClientSideSort, queryFn, + itemCount, ], ); diff --git a/src/renderer/features/albums/components/album-list-header-filters.tsx b/src/renderer/features/albums/components/album-list-header-filters.tsx index 268458b74..544115578 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -139,14 +139,61 @@ const FILTERS = { value: AlbumListSort.YEAR, }, ], + subsonic: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), + value: AlbumListSort.ALBUM_ARTIST, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }), + value: AlbumListSort.PLAY_COUNT, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: AlbumListSort.NAME, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.random', { postProcess: 'titleCase' }), + value: AlbumListSort.RANDOM, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }), + value: AlbumListSort.RECENTLY_ADDED, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }), + value: AlbumListSort.RECENTLY_PLAYED, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.favorited', { postProcess: 'titleCase' }), + value: AlbumListSort.FAVORITED, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }), + value: AlbumListSort.YEAR, + }, + ], }; interface AlbumListHeaderFiltersProps { gridRef: MutableRefObject; + itemCount: number | undefined; tableRef: MutableRefObject; } -export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => { +export const AlbumListHeaderFilters = ({ + gridRef, + tableRef, + itemCount, +}: AlbumListHeaderFiltersProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); const { pageKey, customFilters, handlePlay } = useListContext(); @@ -159,6 +206,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil const cq = useContainerQuery(); const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.ALBUM, server, }); diff --git a/src/renderer/features/albums/components/album-list-header.tsx b/src/renderer/features/albums/components/album-list-header.tsx index 657802b0c..b301468b6 100644 --- a/src/renderer/features/albums/components/album-list-header.tsx +++ b/src/renderer/features/albums/components/album-list-header.tsx @@ -38,6 +38,7 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi const playButtonBehavior = usePlayButtonBehavior(); const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.ALBUM, server, }); @@ -94,6 +95,7 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi diff --git a/src/renderer/features/albums/queries/album-list-count-query.ts b/src/renderer/features/albums/queries/album-list-count-query.ts new file mode 100644 index 000000000..218a67afd --- /dev/null +++ b/src/renderer/features/albums/queries/album-list-count-query.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import type { AlbumListQuery } from '/@/renderer/api/types'; +import type { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { getServerById } from '/@/renderer/store'; + +export const getAlbumListCountQuery = (query: AlbumListQuery) => { + const filter: Record = {}; + + if (query.artistIds) filter.artistIds = query.artistIds; + if (query.maxYear) filter.maxYear = query.maxYear; + if (query.minYear) filter.minYear = query.minYear; + if (query.searchTerm) filter.searchTerm = query.searchTerm; + if (query.musicFolderId) filter.musicFolderId = query.musicFolderId; + + if (Object.keys(filter).length === 0) return undefined; + + return filter; +}; + +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 || '', getAlbumListCountQuery(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 b05a15deb..30fcaa2e5 100644 --- a/src/renderer/features/albums/routes/album-list-route.tsx +++ b/src/renderer/features/albums/routes/album-list-route.tsx @@ -9,12 +9,12 @@ 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'; import { useCurrentServer, useListFilterByKey } from '/@/renderer/store'; import { Play } from '/@/renderer/types'; +import { useAlbumListCount } from '/@/renderer/features/albums/queries/album-list-count-query'; const AlbumListRoute = () => { const gridRef = useRef(null); @@ -42,23 +42,18 @@ const AlbumListRoute = () => { key: pageKey, }); - 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 }) => { 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 5d93d8620..003cc83a0 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 @@ -85,6 +85,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 { 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 b2048cab6..27f5b7483 100644 --- a/src/renderer/features/artists/components/album-artist-list-header.tsx +++ b/src/renderer/features/artists/components/album-artist-list-header.tsx @@ -35,6 +35,7 @@ export const AlbumArtistListHeader = ({ const cq = useContainerQuery(); const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.ALBUM_ARTIST, server, }); 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 000000000..6330dd0c8 --- /dev/null +++ b/src/renderer/features/artists/queries/album-artist-list-count-query.ts @@ -0,0 +1,38 @@ +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 getAlbumArtistListCountQuery = (query: AlbumArtistListQuery) => { + const filter: Record = {}; + + if (query.searchTerm) filter.searchTerm = query.searchTerm; + if (query.musicFolderId) filter.musicFolderId = query.musicFolderId; + + if (Object.keys(filter).length === 0) return undefined; + + return filter; +}; + +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 || '', getAlbumArtistListCountQuery(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 0f68bd57d..83fc4acec 100644 --- a/src/renderer/features/artists/routes/album-artist-list-route.tsx +++ b/src/renderer/features/artists/routes/album-artist-list-route.tsx @@ -7,7 +7,7 @@ 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 { useAlbumArtistListCount } from '/@/renderer/features/artists/queries/album-artist-list-count-query'; import { AnimatedPage } from '/@/renderer/features/shared'; const AlbumArtistListRoute = () => { @@ -18,23 +18,18 @@ const AlbumArtistListRoute = () => { const albumArtistListFilter = useListFilterByKey({ key: pageKey }); - const itemCountCheck = useAlbumArtistList({ + const itemCountCheck = useAlbumArtistListCount({ options: { cacheTime: 1000 * 60, staleTime: 1000 * 60, }, query: { - limit: 1, - startIndex: 0, ...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-header-filters.tsx b/src/renderer/features/genres/components/genre-list-header-filters.tsx index 1bf92863d..2353f71a7 100644 --- a/src/renderer/features/genres/components/genre-list-header-filters.tsx +++ b/src/renderer/features/genres/components/genre-list-header-filters.tsx @@ -37,14 +37,36 @@ const FILTERS = { value: GenreListSort.NAME, }, ], + subsonic: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: GenreListSort.NAME, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }), + value: GenreListSort.ALBUM_COUNT, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.songCount', { postProcess: 'titleCase' }), + value: GenreListSort.SONG_COUNT, + }, + ], }; interface GenreListHeaderFiltersProps { gridRef: MutableRefObject; + itemCount: number | undefined; tableRef: MutableRefObject; } -export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => { +export const GenreListHeaderFilters = ({ + gridRef, + tableRef, + itemCount, +}: GenreListHeaderFiltersProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); const { pageKey, customFilters } = useListContext(); @@ -54,6 +76,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil const cq = useContainerQuery(); 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 8d9176734..f9e22476e 100644 --- a/src/renderer/features/genres/components/genre-list-header.tsx +++ b/src/renderer/features/genres/components/genre-list-header.tsx @@ -34,6 +34,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade const { setFilter, setTablePagination } = useListStoreActions(); const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.GENRE, server, }); @@ -89,6 +90,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade 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 cb0c51c38..a8481b2da 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 15fc3a3d6..9f3c9e25a 100644 --- a/src/renderer/features/playlists/components/playlist-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header.tsx @@ -44,6 +44,7 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis }; const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.PLAYLIST, server, }); 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 97a6365be..3d814b867 100644 --- a/src/renderer/features/songs/components/song-list-header-filters.tsx +++ b/src/renderer/features/songs/components/song-list-header-filters.tsx @@ -160,14 +160,26 @@ 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 | undefined; tableRef: MutableRefObject; } -export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFiltersProps) => { +export const SongListHeaderFilters = ({ + gridRef, + tableRef, + itemCount, +}: SongListHeaderFiltersProps) => { const { t } = useTranslation(); const server = useCurrentServer(); const { pageKey, handlePlay, customFilters } = useListContext(); @@ -179,6 +191,7 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte useListStoreActions(); const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.SONG, server, }); diff --git a/src/renderer/features/songs/components/song-list-header.tsx b/src/renderer/features/songs/components/song-list-header.tsx index b17b86ecd..90e54c72b 100644 --- a/src/renderer/features/songs/components/song-list-header.tsx +++ b/src/renderer/features/songs/components/song-list-header.tsx @@ -32,6 +32,7 @@ export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongList const cq = useContainerQuery(); const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.SONG, server, }); @@ -94,6 +95,7 @@ export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongList diff --git a/src/renderer/features/songs/queries/song-list-count-query.ts b/src/renderer/features/songs/queries/song-list-count-query.ts new file mode 100644 index 000000000..fe21813ab --- /dev/null +++ b/src/renderer/features/songs/queries/song-list-count-query.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import type { SongListQuery } from '/@/renderer/api/types'; +import type { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { getServerById } from '/@/renderer/store'; + +export const getSongListCountQuery = (query: SongListQuery) => { + const filter: Record = {}; + + if (query.searchTerm) filter.searchTerm = query.searchTerm; + if (query.genreId) filter.genreId = query.genreId; + if (query.musicFolderId) filter.musicFolderId = query.musicFolderId; + + if (Object.keys(filter).length === 0) return undefined; + + return filter; +}; + +export const useSongListCount = (args: QueryHookArgs) => { + const { options, query, serverId } = args; + const server = getServerById(serverId); + + return useQuery({ + enabled: !!serverId, + queryFn: ({ signal }) => { + if (!server) throw new Error('Server not found'); + return api.controller.getSongListCount({ + apiClientProps: { + server, + signal, + }, + query, + }); + }, + queryKey: queryKeys.songs.count(serverId || '', getSongListCountQuery(query)), + ...options, + }); +}; diff --git a/src/renderer/features/songs/routes/song-list-route.tsx b/src/renderer/features/songs/routes/song-list-route.tsx index 61a8f070c..3fc974aba 100644 --- a/src/renderer/features/songs/routes/song-list-route.tsx +++ b/src/renderer/features/songs/routes/song-list-route.tsx @@ -9,11 +9,11 @@ import { usePlayQueueAdd } from '/@/renderer/features/player'; import { AnimatedPage } from '/@/renderer/features/shared'; import { SongListContent } from '/@/renderer/features/songs/components/song-list-content'; import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header'; -import { useSongList } from '/@/renderer/features/songs/queries/song-list-query'; import { useCurrentServer, useListFilterByKey } from '/@/renderer/store'; import { Play } from '/@/renderer/types'; import { titleCase } from '/@/renderer/utils'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; +import { useSongListCount } from '/@/renderer/features/songs/queries/song-list-count-query'; const TrackListRoute = () => { const gridRef = useRef(null); @@ -36,6 +36,7 @@ const TrackListRoute = () => { genre_id: genreId, }, }, + genreId, }), }; @@ -74,7 +75,7 @@ const TrackListRoute = () => { return genre?.name; }, [genreId, genreList.data]); - const itemCountCheck = useSongList({ + const itemCountCheck = useSongListCount({ options: { cacheTime: 1000 * 60, staleTime: 1000 * 60, @@ -87,10 +88,7 @@ const TrackListRoute = () => { serverId: server?.id, }); - const itemCount = - itemCountCheck.data?.totalRecordCount === null - ? undefined - : itemCountCheck.data?.totalRecordCount; + const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data; const handlePlay = useCallback( async (args: { initialSongId?: string; playType: Play }) => { diff --git a/src/renderer/hooks/use-list-filter-refresh.ts b/src/renderer/hooks/use-list-filter-refresh.ts index 91ccb000b..d34dd4ff0 100644 --- a/src/renderer/hooks/use-list-filter-refresh.ts +++ b/src/renderer/hooks/use-list-filter-refresh.ts @@ -10,6 +10,7 @@ import orderBy from 'lodash/orderBy'; interface UseHandleListFilterChangeProps { isClientSideSort?: boolean; + itemCount?: number; itemType: LibraryItem; server: ServerListItem | null; } @@ -18,6 +19,7 @@ export const useListFilterRefresh = ({ server, itemType, isClientSideSort, + itemCount, }: UseHandleListFilterChangeProps) => { const queryClient = useQueryClient(); @@ -98,11 +100,14 @@ export const useListFilterRefresh = ({ filter.sortOrder === 'DESC' ? ['desc'] : ['asc'], ); - params.successCallback(sortedResults || [], res?.totalRecordCount || 0); + params.successCallback( + sortedResults || [], + res?.totalRecordCount || itemCount, + ); return; } - params.successCallback(res?.items || [], res?.totalRecordCount || 0); + params.successCallback(res?.items || [], res?.totalRecordCount || itemCount); }, rowCount: undefined, @@ -112,7 +117,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( From 567424011fc117ab1135fa8af15d874dcfeac1f3 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 5 Dec 2023 18:33:26 -0800 Subject: [PATCH 11/23] Add subsonic in server entry form --- src/renderer/features/servers/components/add-server-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/features/servers/components/add-server-form.tsx b/src/renderer/features/servers/components/add-server-form.tsx index 16fd21b88..569e4e57a 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 { From 8ce2a99d37d8705a4032d3a39aa538782861a17f Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 5 Dec 2023 18:33:50 -0800 Subject: [PATCH 12/23] Refactor sidebar playlist --- .../components/sidebar-playlist-list.tsx | 26 ++++++++++++------- .../features/sidebar/components/sidebar.tsx | 20 ++++---------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx index 8078043ca..934679860 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { RiAddBoxFill, RiAddCircleFill, RiPlayFill } from 'react-icons/ri'; import { generatePath } from 'react-router'; import { Link } from 'react-router-dom'; -import { LibraryItem } from '/@/renderer/api/types'; +import { LibraryItem, PlaylistListSort, SortOrder } from '/@/renderer/api/types'; import { Button, Text } from '/@/renderer/components'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlaylistList } from '/@/renderer/features/playlists'; @@ -14,11 +14,7 @@ import { Play } from '/@/renderer/types'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList, ListChildComponentProps } from 'react-window'; import { useHideScrollbar } from '/@/renderer/hooks'; -import { useGeneralSettings } from '/@/renderer/store'; - -interface SidebarPlaylistListProps { - data: ReturnType['data']; -} +import { useCurrentServer, useGeneralSettings } from '/@/renderer/store'; const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => { const { t } = useTranslation(); @@ -121,10 +117,20 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => { ); }; -export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => { +export const SidebarPlaylistList = () => { const { isScrollbarHidden, hideScrollbarElementProps } = useHideScrollbar(0); const handlePlayQueueAdd = usePlayQueueAdd(); const { defaultFullPlaylist } = useGeneralSettings(); + const server = useCurrentServer(); + + const playlistsQuery = usePlaylistList({ + query: { + sortBy: PlaylistListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: server?.id, + }); const [rect, setRect] = useState({ height: 0, @@ -150,9 +156,9 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => { return { defaultFullPlaylist, handlePlay: handlePlayPlaylist, - items: data?.items, + items: playlistsQuery?.data?.items, }; - }, [data?.items, defaultFullPlaylist, handlePlayPlaylist]); + }, [playlistsQuery?.data?.items, defaultFullPlaylist, handlePlayPlaylist]); return ( { : 'overlay-scrollbar' } height={debounced.height} - itemCount={data?.items?.length || 0} + itemCount={playlistsQuery?.data?.items?.length || 0} itemData={memoizedItemData} itemSize={25} overscanCount={20} diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index 9a823c93b..e9caeb464 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -1,7 +1,7 @@ -import { MouseEvent, useMemo } from 'react'; import { Box, Center, Divider, Group, Stack } from '@mantine/core'; import { closeAllModals, openModal } from '@mantine/modals'; import { AnimatePresence, motion } from 'framer-motion'; +import { MouseEvent, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { RiAddFill, RiArrowDownSLine, RiDiscLine, RiListUnordered } from 'react-icons/ri'; import { Link, useLocation } from 'react-router-dom'; @@ -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 && } - + )} From 3c691d23d97c20f1272b6f670f515fedaba7565d Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 5 Dec 2023 18:51:30 -0800 Subject: [PATCH 13/23] Return similar artists on artist detail --- src/renderer/api/subsonic/subsonic-controller.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index a7f1a10ab..298da18c7 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -193,7 +193,10 @@ export const SubsonicController: ControllerEndpoint = { albums: artist.album.map((album) => subsonicNormalize.album(album, apiClientProps.server), ), - artistInfo, + similarArtists: + artistInfo?.similarArtist?.map((artist) => + subsonicNormalize.albumArtist(artist, apiClientProps.server, 300), + ) || null, }; }, getAlbumArtistList: async (args) => { From 18ec50b2a37a54898c51e9dcbc51db56e6280d61 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 5 Dec 2023 19:04:16 -0800 Subject: [PATCH 14/23] Support album and artist detail pages for subsonic --- .../api/jellyfin/jellyfin-controller.ts | 1 + .../api/navidrome/navidrome-controller.ts | 2 + .../api/subsonic/subsonic-controller.ts | 39 +++++++++++++++++++ src/renderer/api/types.ts | 1 + .../album-artist-detail-content.tsx | 3 ++ 5 files changed, 46 insertions(+) diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 4112fd8a3..e7cd37dd3 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -333,6 +333,7 @@ const getAlbumList = async (args: AlbumListArgs): Promise => AlbumArtistIds: query.artistIds ? formatCommaDelimitedString(query.artistIds) : undefined, + ContributingArtistIds: query.isCompilation ? query.artistIds?.[0] : undefined, IncludeItemTypes: 'MusicAlbum', Limit: query.limit, ParentId: query.musicFolderId, diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index abb23bb99..18a640718 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -233,6 +233,8 @@ const getAlbumList = async (args: AlbumListArgs): Promise => _sort: albumListSortMap.navidrome[query.sortBy], _start: query.startIndex, artist_id: query.artistIds?.[0], + compilation: query.isCompilation, + genre_id: query.genre, name: query.searchTerm, ...query._custom?.navidrome, }, diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 298da18c7..7e21bbc1e 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -332,6 +332,45 @@ export const SubsonicController: ControllerEndpoint = { [AlbumListSort.SONG_COUNT]: undefined, }; + if (query.isCompilation) { + return { + items: [], + startIndex: 0, + totalRecordCount: 0, + }; + } + + if (query.artistIds) { + const promises = []; + + for (const artistId of query.artistIds) { + promises.push( + subsonicApiClient(apiClientProps).getArtist({ + query: { + id: artistId, + }, + }), + ); + } + + const artistResult = await Promise.all(promises); + + const albums = artistResult.flatMap((artist) => { + if (artist.status !== 200) { + fsLog.warn('Failed to get artist detail', { context: { artist } }); + return []; + } + + return artist.body['subsonic-response'].artist.album; + }); + + return { + items: albums.map((album) => subsonicNormalize.album(album, apiClientProps.server)), + startIndex: 0, + totalRecordCount: albums.length, + }; + } + const res = await subsonicApiClient(apiClientProps).getAlbumList2({ query: { fromYear: query.minYear, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 153bb928b..fc6f8dc99 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -377,6 +377,7 @@ export type AlbumListQuery = { }; artistIds?: string[]; genre?: string; + isCompilation?: boolean; limit?: number; maxYear?: number; minYear?: number; 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 3d744aeed..54863d469 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -100,6 +100,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten : undefined), }, }, + artistIds: [albumArtistId], limit: 15, sortBy: AlbumListSort.RELEASE_DATE, sortOrder: SortOrder.DESC, @@ -122,6 +123,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten : undefined), }, }, + artistIds: [albumArtistId], + isCompilation: true, limit: 15, sortBy: AlbumListSort.RELEASE_DATE, sortOrder: SortOrder.DESC, From d347221be51292529e3973b09cbb57066acc5f23 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 8 Dec 2023 08:06:56 -0800 Subject: [PATCH 15/23] Support playlists --- .../api/jellyfin/jellyfin-controller.ts | 3 +- .../api/navidrome/navidrome-controller.ts | 7 +- .../api/subsonic/subsonic-controller.ts | 143 ++++++++++++++- src/renderer/api/subsonic/subsonic-types.ts | 2 +- src/renderer/api/types.ts | 8 +- src/renderer/features/player/utils.ts | 5 +- .../add-to-playlist-context-modal.tsx | 7 +- .../components/create-playlist-form.tsx | 6 +- .../playlist-detail-song-list-content.tsx | 75 +------- ...aylist-detail-song-list-header-filters.tsx | 165 ++++++++++-------- .../playlist-detail-song-list-header.tsx | 43 +++-- .../playlist-detail-song-list-route.tsx | 25 ++- 12 files changed, 298 insertions(+), 191 deletions(-) diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index e7cd37dd3..c8ac48646 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -535,7 +535,6 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise jfNormalize.song(item, apiClientProps.server, '')), - startIndex: query.startIndex, + startIndex: 0, totalRecordCount: res.body.TotalRecordCount, }; }; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 18a640718..166b23ddc 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -303,7 +303,7 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise ndNormalize.song(item, apiClientProps.server, '')), - startIndex: query?.startIndex || 0, + startIndex: 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 7e21bbc1e..9bf4c12fb 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1,5 +1,7 @@ import orderBy from 'lodash/orderBy'; +import shuffle from 'lodash/shuffle'; import filter from 'lodash/filter'; +import reverse from 'lodash/reverse'; import md5 from 'md5'; import { fsLog } from '/@/logger'; import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api'; @@ -13,6 +15,7 @@ import { GenreListSort, LibraryItem, PlaylistListSort, + SongListSort, } from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; @@ -566,6 +569,25 @@ export const SubsonicController: ControllerEndpoint = { totalRecordCount: res.body['subsonic-response'].musicFolders.musicFolder.length, }; }, + getPlaylistDetail: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getPlaylist({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get playlist detail'); + throw new Error('Failed to get playlist detail'); + } + + return subsonicNormalize.playlist( + res.body['subsonic-response'].playlist, + apiClientProps.server, + ); + }, getPlaylistList: async (args) => { const { query, apiClientProps } = args; const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; @@ -619,7 +641,7 @@ export const SubsonicController: ControllerEndpoint = { }; }, getPlaylistListCount: async (args) => { - const { apiClientProps } = args; + const { query, apiClientProps } = args; const res = await subsonicApiClient(apiClientProps).getPlaylists({}); @@ -628,8 +650,127 @@ export const SubsonicController: ControllerEndpoint = { throw new Error('Failed to get playlist list count'); } + if (query.searchTerm) { + const searchResults = filter( + res.body['subsonic-response'].playlists.playlist, + (playlist) => { + return playlist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }, + ); + + return searchResults.length; + } + return res.body['subsonic-response'].playlists.playlist.length; }, + getPlaylistSongList: async (args) => { + const { query, apiClientProps } = args; + const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; + + const res = await subsonicApiClient(apiClientProps).getPlaylist({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get playlist song list'); + throw new Error('Failed to get playlist song list'); + } + + let results = res.body['subsonic-response'].playlist.entry || []; + + if (query.searchTerm) { + const searchResults = filter(results, (entry) => { + return entry.title.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + } + + if (query.sortBy) { + switch (query.sortBy) { + case SongListSort.ALBUM: + results = orderBy( + results, + [(v) => v.album?.toLowerCase(), 'discNumber', 'track'], + [sortOrder, 'asc', 'asc'], + ); + break; + case SongListSort.ALBUM_ARTIST: + results = orderBy( + results, + ['albumArtist', (v) => v.album?.toLowerCase(), 'discNumber', 'track'], + [sortOrder, sortOrder, 'asc', 'asc'], + ); + break; + case SongListSort.ARTIST: + results = orderBy( + results, + ['artist', (v) => v.album?.toLowerCase(), 'discNumber', 'track'], + [sortOrder, sortOrder, 'asc', 'asc'], + ); + break; + case SongListSort.DURATION: + results = orderBy(results, ['duration'], [sortOrder]); + break; + case SongListSort.FAVORITED: + results = orderBy( + results, + ['starred', (v) => v.title.toLowerCase()], + [sortOrder], + ); + break; + case SongListSort.GENRE: + results = orderBy( + results, + ['genre', (v) => v.album?.toLowerCase(), 'discNumber', 'track'], + [sortOrder, sortOrder, 'asc', 'asc'], + ); + break; + case SongListSort.ID: + if (sortOrder === 'desc') { + results = reverse(results); + } + break; + case SongListSort.NAME: + results = orderBy(results, [(v) => v.title.toLowerCase()], [sortOrder]); + break; + case SongListSort.PLAY_COUNT: + results = orderBy(results, ['playCount'], [sortOrder]); + break; + case SongListSort.RANDOM: + results = shuffle(results); + break; + case SongListSort.RATING: + results = orderBy( + results, + ['userRating', (v) => v.title.toLowerCase()], + [sortOrder], + ); + break; + case SongListSort.RECENTLY_ADDED: + results = orderBy(results, ['created'], [sortOrder]); + break; + case SongListSort.YEAR: + results = orderBy( + results, + ['year', (v) => v.album?.toLowerCase(), 'discNumber', 'track'], + [sortOrder, 'asc', 'asc', 'asc'], + ); + break; + + default: + break; + } + } + + return { + items: results?.map((song) => subsonicNormalize.song(song, apiClientProps.server, '')), + startIndex: 0, + totalRecordCount: results?.length || 0, + }; + }, getRandomSongList: async (args) => { const { query, apiClientProps } = args; diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index c8e825b1b..20ec25b29 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -109,7 +109,7 @@ const playlist = z.object({ coverArt: z.string().optional(), created: z.string(), duration: z.number(), - entry: z.array(song), + entry: z.array(song).optional(), id: z.string(), name: z.string(), owner: z.string(), diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index fc6f8dc99..0f12aea77 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -815,6 +815,7 @@ export type CreatePlaylistBody = { }; comment?: string; name: string; + public?: boolean; }; export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs; @@ -935,10 +936,9 @@ export type PlaylistSongListResponse = BasePaginatedResponse | null | un export type PlaylistSongListQuery = { id: string; - limit?: number; - sortBy?: SongListSort; - sortOrder?: SortOrder; - startIndex: number; + searchTerm?: string; + sortBy: SongListSort; + sortOrder: SortOrder; }; export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs; diff --git a/src/renderer/features/player/utils.ts b/src/renderer/features/player/utils.ts index 0b16f2aab..ffa102e9e 100644 --- a/src/renderer/features/player/utils.ts +++ b/src/renderer/features/player/utils.ts @@ -23,7 +23,6 @@ export const getPlaylistSongsById = async (args: { id, sortBy: SongListSort.ID, sortOrder: SortOrder.ASC, - startIndex: 0, ...query, }; @@ -139,7 +138,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/add-to-playlist-context-modal.tsx b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx index 08a623a22..2b53de50b 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 @@ -136,7 +136,6 @@ export const AddToPlaylistContextModal = ({ if (values.skipDuplicates) { const query = { id: playlistId, - startIndex: 0, }; const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query); @@ -151,7 +150,11 @@ export const AddToPlaylistContextModal = ({ server, signal, }, - query: { id: playlistId, startIndex: 0 }, + query: { + id: playlistId, + sortBy: SongListSort.ID, + sortOrder: SortOrder.ASC, + }, }); }); diff --git a/src/renderer/features/playlists/components/create-playlist-form.tsx b/src/renderer/features/playlists/components/create-playlist-form.tsx index 63c9fc477..9faa8eb45 100644 --- a/src/renderer/features/playlists/components/create-playlist-form.tsx +++ b/src/renderer/features/playlists/components/create-playlist-form.tsx @@ -32,6 +32,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => { }, comment: '', name: '', + public: false, }, }); const [isSmartPlaylist, setIsSmartPlaylist] = useState(false); @@ -86,7 +87,8 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => { ); }); - const isPublicDisplayed = server?.type === ServerType.NAVIDROME; + const isPublicDisplayed = + server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC; const isSubmitDisabled = !form.values.name || mutation.isLoading; return ( @@ -115,7 +117,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => { context: 'public', postProcess: 'titleCase', })} - {...form.getInputProps('_custom.navidrome.public', { + {...form.getInputProps('public', { type: 'checkbox', })} /> 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 dc4063525..070196efa 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 @@ -2,25 +2,15 @@ import type { BodyScrollEvent, ColDef, GridReadyEvent, - IDatasource, PaginationChangedEvent, RowDoubleClickedEvent, } from '@ag-grid-community/core'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { useQueryClient } from '@tanstack/react-query'; import { AnimatePresence } from 'framer-motion'; import debounce from 'lodash/debounce'; import { MutableRefObject, useCallback, useMemo } from 'react'; import { useParams } from 'react-router'; -import { api } from '/@/renderer/api'; -import { queryKeys } from '/@/renderer/api/query-keys'; -import { - LibraryItem, - PlaylistSongListQuery, - QueueSong, - SongListSort, - SortOrder, -} from '/@/renderer/api/types'; +import { LibraryItem, QueueSong, Song } from '/@/renderer/api/types'; import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid'; import { TablePagination, VirtualTable, getColumnDefs } from '/@/renderer/components/virtual-table'; import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles'; @@ -31,7 +21,7 @@ import { } from '/@/renderer/features/context-menu/context-menu-items'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; -import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query'; +import { useAppFocus } from '/@/renderer/hooks'; import { useCurrentServer, useCurrentSong, @@ -43,26 +33,19 @@ import { } from '/@/renderer/store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { ListDisplayType } from '/@/renderer/types'; -import { useAppFocus } from '/@/renderer/hooks'; 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(); const isFocused = useAppFocus(); const currentSong = useCurrentSong(); const server = useCurrentServer(); const page = usePlaylistDetailStore(); - const filters: Partial = useMemo(() => { - return { - sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID, - sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC, - }; - }, [page?.table.id, playlistId]); const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id }); @@ -82,15 +65,6 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED; - const checkPlaylistList = usePlaylistSongList({ - query: { - id: playlistId, - limit: 1, - startIndex: 0, - }, - serverId: server?.id, - }); - const columnDefs: ColDef[] = useMemo( () => getColumnDefs(page.table.columns, false, 'generic'), [page.table.columns], @@ -98,44 +72,9 @@ 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, - }, - 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], + [pagination.scrollOffset], ); const handleGridSizeChange = () => { @@ -249,13 +188,13 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten status, }} getRowId={(data) => data.data.uniqueId} - infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100} pagination={isPaginationEnabled} paginationAutoPageSize={isPaginationEnabled} paginationPageSize={pagination.itemsPerPage || 100} rowClassRules={rowClassRules} + rowData={songs} rowHeight={page.table.rowHeight || 40} - rowModelType="infinite" + rowModelType="clientSide" 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 83d4311a1..3e7fc60cf 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 @@ -1,53 +1,50 @@ -import { useCallback, ChangeEvent, MutableRefObject, MouseEvent } from 'react'; -import { IDatasource } from '@ag-grid-community/core'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { Divider, Flex, Group, Stack } from '@mantine/core'; import { closeAllModals, openModal } from '@mantine/modals'; import { useQueryClient } from '@tanstack/react-query'; +import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { - RiMoreFill, - RiSettings3Fill, - RiPlayFill, - RiAddCircleFill, RiAddBoxFill, - RiEditFill, + RiAddCircleFill, RiDeleteBinFill, + RiEditFill, + RiMoreFill, + RiPlayFill, RiRefreshLine, + RiSettings3Fill, } from 'react-icons/ri'; -import { api } from '/@/renderer/api'; +import { useNavigate, useParams } from 'react-router'; +import i18n from '/@/i18n/i18n'; import { queryKeys } from '/@/renderer/api/query-keys'; import { LibraryItem, PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types'; import { - DropdownMenu, Button, - Slider, + ConfirmModal, + DropdownMenu, MultiSelect, + Slider, Switch, Text, - ConfirmModal, toast, } from '/@/renderer/components'; +import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; 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 { OrderToggleButton } from '/@/renderer/features/shared'; import { useContainerQuery } from '/@/renderer/hooks'; +import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer, - SongListFilter, usePlaylistDetailStore, useSetPlaylistDetailFilters, useSetPlaylistDetailTable, useSetPlaylistStore, useSetPlaylistTablePagination, } from '/@/renderer/store'; -import { ListDisplayType, ServerType, Play, TableColumn } from '/@/renderer/types'; -import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; -import { useParams, useNavigate } from 'react-router'; -import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; -import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form'; -import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation'; -import { AppRoute } from '/@/renderer/router/routes'; -import { OrderToggleButton } from '/@/renderer/features/shared'; -import i18n from '/@/i18n/i18n'; +import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types'; const FILTERS = { jellyfin: [ @@ -150,7 +147,7 @@ const FILTERS = { }, { defaultOrder: SortOrder.ASC, - name: i18n.t('filter.playCount', { postProcess: 'titleCase' }), + name: i18n.t('filter.genre', { postProcess: 'titleCase' }), value: SongListSort.GENRE, }, { @@ -184,6 +181,68 @@ const FILTERS = { value: SongListSort.YEAR, }, ], + subsonic: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.id', { postProcess: 'titleCase' }), + value: SongListSort.ID, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.album', { postProcess: 'titleCase' }), + value: SongListSort.ALBUM, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), + value: SongListSort.ALBUM_ARTIST, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.artist', { postProcess: 'titleCase' }), + value: SongListSort.ARTIST, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.duration', { postProcess: 'titleCase' }), + value: SongListSort.DURATION, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }), + value: SongListSort.FAVORITED, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.genre', { postProcess: 'titleCase' }), + value: SongListSort.GENRE, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: SongListSort.NAME, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.rating', { postProcess: 'titleCase' }), + value: SongListSort.RATING, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }), + value: SongListSort.RECENTLY_ADDED, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }), + value: SongListSort.RECENTLY_PLAYED, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }), + value: SongListSort.YEAR, + }, + ], }; interface PlaylistDetailSongListHeaderFiltersProps { @@ -228,56 +287,18 @@ export const PlaylistDetailSongListHeaderFilters = ({ setTable({ rowHeight: e }); }; - 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'); + const handleFilterChange = useCallback(async () => { + tableRef.current?.api.redrawRows(); + tableRef.current?.api.ensureIndexVisible(0, 'top'); - if (page.display === ListDisplayType.TABLE_PAGINATED) { - setPagination({ data: { currentPage: 0 } }); - } - }, - [tableRef, page.display, server, playlistId, queryClient, setPagination], - ); + if (page.display === ListDisplayType.TABLE_PAGINATED) { + setPagination({ data: { currentPage: 0 } }); + } + }, [tableRef, page.display, setPagination]); const handleRefresh = () => { queryClient.invalidateQueries(queryKeys.albums.list(server?.id || '')); - handleFilterChange({ ...page?.table.id[playlistId].filter, ...filters }); + handleFilterChange(); }; const handleSetSortBy = useCallback( @@ -288,20 +309,20 @@ export const PlaylistDetailSongListHeaderFilters = ({ (f) => f.value === e.currentTarget.value, )?.defaultOrder; - const updatedFilters = setFilter(playlistId, { + setFilter(playlistId, { sortBy: e.currentTarget.value as SongListSort, sortOrder: sortOrder || SortOrder.ASC, }); - handleFilterChange(updatedFilters); + handleFilterChange(); }, [handleFilterChange, playlistId, server?.type, setFilter], ); const handleToggleSortOrder = useCallback(() => { const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC; - const updatedFilters = setFilter(playlistId, { sortOrder: newSortOrder }); - handleFilterChange(updatedFilters); + setFilter(playlistId, { sortOrder: newSortOrder }); + handleFilterChange(); }, [filters.sortOrder, handleFilterChange, playlistId, setFilter]); const handleSetViewType = useCallback( diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx index 29e7a51ec..4a25d3f03 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx @@ -1,6 +1,6 @@ import { MutableRefObject } from 'react'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Stack } from '@mantine/core'; +import { Flex, Stack } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router'; import { LibraryItem } from '/@/renderer/api/types'; @@ -45,23 +45,30 @@ export const PlaylistDetailSongListHeader = ({ return ( - - handlePlay(playButtonBehavior)} /> - {detailQuery?.data?.name} - - {itemCount === null || itemCount === undefined ? ( - - ) : ( - itemCount - )} - - {isSmartPlaylist && {t('entity.smartPlaylist')}} - + + + handlePlay(playButtonBehavior)} + /> + {detailQuery?.data?.name} + + {itemCount === null || itemCount === undefined ? ( + + ) : ( + itemCount + )} + + {isSmartPlaylist && {t('entity.smartPlaylist')}} + + { const page = usePlaylistDetailStore(); const filters: Partial = { - sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID, - sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC, + sortBy: page?.table.id[playlistId]?.filter?.sortBy, + sortOrder: page?.table.id[playlistId]?.filter?.sortOrder, }; - const itemCountCheck = usePlaylistSongList({ - options: { - cacheTime: 1000 * 60 * 60 * 2, - staleTime: 1000 * 60 * 60 * 2, - }, + const { data } = usePlaylistSongList({ query: { id: playlistId, - limit: 1, - startIndex: 0, - ...filters, + sortBy: filters.sortBy || SongListSort.ID, + sortOrder: filters.sortOrder || SortOrder.ASC, }, serverId: server?.id, }); - const itemCount = - itemCountCheck.data?.totalRecordCount === null - ? undefined - : itemCountCheck.data?.totalRecordCount; + const itemCount = data?.items.length; return ( @@ -206,7 +198,10 @@ const PlaylistDetailSongListRoute = () => { )} - + ); }; From 8493668c974ce2324235763dbc15251bc893866c Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 8 Dec 2023 08:12:09 -0800 Subject: [PATCH 16/23] Remove default playlist page --- src/renderer/components/card/card-rows.tsx | 2 +- .../virtual-table/hooks/use-virtual-table.ts | 4 +- .../components/playlist-detail-content.tsx | 254 ------------------ .../components/playlist-detail-header.tsx | 79 ------ .../components/playlist-list-grid-view.tsx | 15 +- .../components/playlist-list-table-view.tsx | 9 +- .../queries/playlist-song-list-query.ts | 58 ++-- .../components/general/control-settings.tsx | 22 -- .../components/sidebar-playlist-list.tsx | 12 +- src/renderer/router/app-router.tsx | 9 - 10 files changed, 43 insertions(+), 421 deletions(-) delete mode 100644 src/renderer/features/playlists/components/playlist-detail-content.tsx delete mode 100644 src/renderer/features/playlists/components/playlist-detail-header.tsx diff --git a/src/renderer/components/card/card-rows.tsx b/src/renderer/components/card/card-rows.tsx index 40b1fa993..21dc29ffd 100644 --- a/src/renderer/components/card/card-rows.tsx +++ b/src/renderer/components/card/card-rows.tsx @@ -275,7 +275,7 @@ export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow } = { name: { property: 'name', route: { - route: AppRoute.PLAYLISTS_DETAIL, + route: AppRoute.PLAYLISTS_DETAIL_SONGS, slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }], }, }, diff --git a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts index a99cdb295..776a6d8c6 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -385,7 +385,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/playlists/components/playlist-detail-content.tsx b/src/renderer/features/playlists/components/playlist-detail-content.tsx deleted file mode 100644 index 12dc2192e..000000000 --- a/src/renderer/features/playlists/components/playlist-detail-content.tsx +++ /dev/null @@ -1,254 +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 - - - - - - - - { - // It's possible that there are duplicate song ids in a playlist - return `${data.data.id}-${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 1235681cd..000000000 --- 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 14abc68e8..3d51e7caa 100644 --- a/src/renderer/features/playlists/components/playlist-list-grid-view.tsx +++ b/src/renderer/features/playlists/components/playlist-list-grid-view.tsx @@ -1,5 +1,5 @@ -import { QueryKey, useQueryClient } from '@tanstack/react-query'; import { MutableRefObject, useCallback, useMemo } from 'react'; +import { QueryKey, useQueryClient } from '@tanstack/react-query'; import AutoSizer, { Size } from 'react-virtualized-auto-sizer'; import { ListOnScrollProps } from 'react-window'; import { useListContext } from '../../../context/list-context'; @@ -22,7 +22,7 @@ import { import { usePlayQueueAdd } from '/@/renderer/features/player'; import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared'; 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'; interface PlaylistListGridViewProps { @@ -37,7 +37,6 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie const handlePlayQueueAdd = usePlayQueueAdd(); const { display, grid, filter } = useListStoreByKey({ key: pageKey }); const { setGrid } = useListStoreActions(); - const { defaultFullPlaylist } = useGeneralSettings(); const createFavoriteMutation = useCreateFavorite({}); const deleteFavoriteMutation = useDeleteFavorite({}); @@ -68,9 +67,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie }; const cardRows = useMemo(() => { - const rows: CardRow[] = defaultFullPlaylist - ? [PLAYLIST_CARD_ROWS.nameFull] - : [PLAYLIST_CARD_ROWS.name]; + const rows: CardRow[] = [PLAYLIST_CARD_ROWS.name]; switch (filter.sortBy) { case PlaylistListSort.DURATION: @@ -93,7 +90,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie } return rows; - }, [defaultFullPlaylist, filter.sortBy]); + }, [filter.sortBy]); const handleGridScroll = useCallback( (e: ListOnScrollProps) => { @@ -187,9 +184,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-table-view.tsx b/src/renderer/features/playlists/components/playlist-list-table-view.tsx index 6decfea2b..af5fe0e3c 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/queries/playlist-song-list-query.ts b/src/renderer/features/playlists/queries/playlist-song-list-query.ts index 8d2699d72..8754fa295 100644 --- a/src/renderer/features/playlists/queries/playlist-song-list-query.ts +++ b/src/renderer/features/playlists/queries/playlist-song-list-query.ts @@ -1,9 +1,9 @@ -import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '/@/renderer/api'; 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'; export const usePlaylistSongList = (args: QueryHookArgs) => { const { options, query, serverId } = args || {}; @@ -23,31 +23,31 @@ export const usePlaylistSongList = (args: QueryHookArgs) }); }; -export const usePlaylistSongListInfinite = (args: QueryHookArgs) => { - const { options, query, serverId } = args || {}; - const server = getServerById(serverId); +// 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 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, - }); -}; +// 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/settings/components/general/control-settings.tsx b/src/renderer/features/settings/components/general/control-settings.tsx index 8e9407d05..d04d979c0 100644 --- a/src/renderer/features/settings/components/general/control-settings.tsx +++ b/src/renderer/features/settings/components/general/control-settings.tsx @@ -246,28 +246,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' }), - }, ]; return ; diff --git a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx index 934679860..ecd636f48 100644 --- a/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx +++ b/src/renderer/features/sidebar/components/sidebar-playlist-list.tsx @@ -14,16 +14,12 @@ import { Play } from '/@/renderer/types'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList, ListChildComponentProps } from 'react-window'; import { useHideScrollbar } from '/@/renderer/hooks'; -import { useCurrentServer, useGeneralSettings } from '/@/renderer/store'; +import { useCurrentServer } from '/@/renderer/store'; const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => { const { t } = useTranslation(); 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 ( @@ -120,7 +116,6 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => { export const SidebarPlaylistList = () => { const { isScrollbarHidden, hideScrollbarElementProps } = useHideScrollbar(0); const handlePlayQueueAdd = usePlayQueueAdd(); - const { defaultFullPlaylist } = useGeneralSettings(); const server = useCurrentServer(); const playlistsQuery = usePlaylistList({ @@ -154,11 +149,10 @@ export const SidebarPlaylistList = () => { const memoizedItemData = useMemo(() => { return { - defaultFullPlaylist, handlePlay: handlePlayPlaylist, items: playlistsQuery?.data?.items, }; - }, [playlistsQuery?.data?.items, defaultFullPlaylist, handlePlayPlaylist]); + }, [playlistsQuery?.data?.items, handlePlayPlaylist]); return ( import('/@/renderer/features/albums/routes/alb const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route')); -const PlaylistDetailRoute = lazy( - () => import('/@/renderer/features/playlists/routes/playlist-detail-route'), -); - const PlaylistDetailSongListRoute = lazy( () => import('/@/renderer/features/playlists/routes/playlist-detail-song-list-route'), ); @@ -136,11 +132,6 @@ export const AppRouter = () => { errorElement={} path={AppRoute.PLAYLISTS} /> - } - errorElement={} - path={AppRoute.PLAYLISTS_DETAIL} - /> } errorElement={} From 50dd70df81d728ff5be7c1356dd838851a7477d4 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 13 Dec 2023 18:15:02 -0800 Subject: [PATCH 17/23] Add global sort utils --- .../api/subsonic/subsonic-controller.ts | 126 ++-------------- src/renderer/api/utils.ts | 137 +++++++++++++++++- 2 files changed, 152 insertions(+), 111 deletions(-) diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 9bf4c12fb..c9a54c176 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1,22 +1,19 @@ -import orderBy from 'lodash/orderBy'; -import shuffle from 'lodash/shuffle'; import filter from 'lodash/filter'; -import reverse from 'lodash/reverse'; +import orderBy from 'lodash/orderBy'; import md5 from 'md5'; import { fsLog } from '/@/logger'; import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api'; import { subsonicNormalize } from '/@/renderer/api/subsonic/subsonic-normalize'; import { AlbumListSortType, SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; import { - AlbumArtistListSort, AlbumListSort, AuthenticationResponse, ControllerEndpoint, GenreListSort, LibraryItem, PlaylistListSort, - SongListSort, } from '/@/renderer/api/types'; +import { sortAlbumArtistList, sortSongList } from '/@/renderer/api/utils'; import { randomString } from '/@/renderer/utils'; const authenticate = async ( @@ -204,7 +201,6 @@ export const SubsonicController: ControllerEndpoint = { }, getAlbumArtistList: async (args) => { const { query, apiClientProps } = args; - const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; const res = await subsonicApiClient(apiClientProps).getArtists({ query: { @@ -221,8 +217,9 @@ export const SubsonicController: ControllerEndpoint = { (index) => index.artist, ); - let results = artists; - let totalRecordCount = artists.length; + let results = artists.map((artist) => + subsonicNormalize.albumArtist(artist, apiClientProps.server), + ); if (query.searchTerm) { const searchResults = filter(results, (artist) => { @@ -230,36 +227,16 @@ export const SubsonicController: ControllerEndpoint = { }); results = searchResults; - totalRecordCount = searchResults.length; } - switch (query.sortBy) { - case AlbumArtistListSort.ALBUM_COUNT: - results = orderBy( - artists, - ['albumCount', (v) => v.name.toLowerCase()], - [sortOrder, 'asc'], - ); - break; - case AlbumArtistListSort.NAME: - results = orderBy(artists, [(v) => v.name.toLowerCase()], [sortOrder]); - break; - case AlbumArtistListSort.FAVORITED: - results = orderBy(artists, ['starred'], [sortOrder]); - break; - case AlbumArtistListSort.RATING: - results = orderBy(artists, ['userRating'], [sortOrder]); - break; - default: - break; + if (query.sortBy) { + sortAlbumArtistList(results, query.sortBy, query.sortOrder); } return { - items: results.map((artist) => - subsonicNormalize.albumArtist(artist, apiClientProps.server), - ), + items: results, startIndex: query.startIndex, - totalRecordCount, + totalRecordCount: results?.length || 0, }; }, getAlbumArtistListCount: async (args) => { @@ -665,7 +642,6 @@ export const SubsonicController: ControllerEndpoint = { }, getPlaylistSongList: async (args) => { const { query, apiClientProps } = args; - const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; const res = await subsonicApiClient(apiClientProps).getPlaylist({ query: { @@ -678,95 +654,25 @@ export const SubsonicController: ControllerEndpoint = { throw new Error('Failed to get playlist song list'); } - let results = res.body['subsonic-response'].playlist.entry || []; + let results = + res.body['subsonic-response'].playlist.entry?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ) || []; if (query.searchTerm) { const searchResults = filter(results, (entry) => { - return entry.title.toLowerCase().includes(query.searchTerm!.toLowerCase()); + return entry.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); }); results = searchResults; } if (query.sortBy) { - switch (query.sortBy) { - case SongListSort.ALBUM: - results = orderBy( - results, - [(v) => v.album?.toLowerCase(), 'discNumber', 'track'], - [sortOrder, 'asc', 'asc'], - ); - break; - case SongListSort.ALBUM_ARTIST: - results = orderBy( - results, - ['albumArtist', (v) => v.album?.toLowerCase(), 'discNumber', 'track'], - [sortOrder, sortOrder, 'asc', 'asc'], - ); - break; - case SongListSort.ARTIST: - results = orderBy( - results, - ['artist', (v) => v.album?.toLowerCase(), 'discNumber', 'track'], - [sortOrder, sortOrder, 'asc', 'asc'], - ); - break; - case SongListSort.DURATION: - results = orderBy(results, ['duration'], [sortOrder]); - break; - case SongListSort.FAVORITED: - results = orderBy( - results, - ['starred', (v) => v.title.toLowerCase()], - [sortOrder], - ); - break; - case SongListSort.GENRE: - results = orderBy( - results, - ['genre', (v) => v.album?.toLowerCase(), 'discNumber', 'track'], - [sortOrder, sortOrder, 'asc', 'asc'], - ); - break; - case SongListSort.ID: - if (sortOrder === 'desc') { - results = reverse(results); - } - break; - case SongListSort.NAME: - results = orderBy(results, [(v) => v.title.toLowerCase()], [sortOrder]); - break; - case SongListSort.PLAY_COUNT: - results = orderBy(results, ['playCount'], [sortOrder]); - break; - case SongListSort.RANDOM: - results = shuffle(results); - break; - case SongListSort.RATING: - results = orderBy( - results, - ['userRating', (v) => v.title.toLowerCase()], - [sortOrder], - ); - break; - case SongListSort.RECENTLY_ADDED: - results = orderBy(results, ['created'], [sortOrder]); - break; - case SongListSort.YEAR: - results = orderBy( - results, - ['year', (v) => v.album?.toLowerCase(), 'discNumber', 'track'], - [sortOrder, 'asc', 'asc', 'asc'], - ); - break; - - default: - break; - } + sortSongList(results, query.sortBy, query.sortOrder); } return { - items: results?.map((song) => subsonicNormalize.song(song, apiClientProps.server, '')), + items: results, startIndex: 0, totalRecordCount: results?.length || 0, }; diff --git a/src/renderer/api/utils.ts b/src/renderer/api/utils.ts index 0063fae95..335c42589 100644 --- a/src/renderer/api/utils.ts +++ b/src/renderer/api/utils.ts @@ -1,8 +1,18 @@ import { AxiosHeaders } from 'axios'; -import { z } from 'zod'; import { toast } from '/@/renderer/components'; import { useAuthStore } from '/@/renderer/store'; import { ServerListItem } from '/@/renderer/types'; +import { + AlbumArtist, + AlbumArtistListSort, + QueueSong, + SongListSort, + SortOrder, +} from '/@/renderer/api/types'; +import orderBy from 'lodash/orderBy'; +import reverse from 'lodash/reverse'; +import shuffle from 'lodash/shuffle'; +import { z } from 'zod'; // Since ts-rest client returns a strict response type, we need to add the headers to the body object export const resultWithHeaders = (itemSchema: ItemType) => { @@ -38,3 +48,128 @@ export const authenticationFailure = (currentServer: ServerListItem | null) => { useAuthStore.getState().actions.setCurrentServer(null); } }; + +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; +}; From 5a94f70e631e9b17ed08750de638ac85ecd0943a Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 18 Dec 2023 11:45:04 -0800 Subject: [PATCH 18/23] Add list count endpoints to jf/nd --- .../api/jellyfin/jellyfin-controller.ts | 277 ++++++++++++++---- .../api/navidrome/navidrome-controller.ts | 117 ++++++-- 2 files changed, 319 insertions(+), 75 deletions(-) diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index c8ac48646..471a60074 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -1,62 +1,64 @@ +import isElectron from 'is-electron'; +import { z } from 'zod'; +import packageJson from '../../../../package.json'; +import { jfNormalize } from './jellyfin-normalize'; +import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types'; +import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; +import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types'; import { - AuthenticationResponse, - MusicFolderListArgs, - MusicFolderListResponse, - GenreListArgs, + AddToPlaylistArgs, + AddToPlaylistResponse, AlbumArtistDetailArgs, + AlbumArtistDetailResponse, AlbumArtistListArgs, - albumArtistListSortMap, - sortOrderMap, - ArtistListArgs, - artistListSortMap, + AlbumArtistListResponse, AlbumDetailArgs, + AlbumDetailResponse, AlbumListArgs, - albumListSortMap, - TopSongListArgs, - SongListArgs, - songListSortMap, - AddToPlaylistArgs, - RemoveFromPlaylistArgs, - PlaylistDetailArgs, - PlaylistSongListArgs, - PlaylistListArgs, - playlistListSortMap, + AlbumListResponse, + AuthenticationResponse, + ControllerEndpoint, CreatePlaylistArgs, CreatePlaylistResponse, - UpdatePlaylistArgs, - UpdatePlaylistResponse, DeletePlaylistArgs, FavoriteArgs, FavoriteResponse, - ScrobbleArgs, - ScrobbleResponse, + GenreListArgs, GenreListResponse, - AlbumArtistDetailResponse, - AlbumArtistListResponse, - AlbumDetailResponse, - AlbumListResponse, - SongListResponse, - AddToPlaylistResponse, - RemoveFromPlaylistResponse, + LyricsArgs, + LyricsResponse, + MusicFolderListArgs, + MusicFolderListResponse, + PlaylistDetailArgs, PlaylistDetailResponse, + PlaylistListArgs, PlaylistListResponse, + PlaylistSongListArgs, + RandomSongListArgs, + RandomSongListResponse, + RemoveFromPlaylistArgs, + RemoveFromPlaylistResponse, + ScrobbleArgs, + ScrobbleResponse, SearchArgs, SearchResponse, - RandomSongListResponse, - RandomSongListArgs, - LyricsArgs, - LyricsResponse, - genreListSortMap, SongDetailArgs, SongDetailResponse, + SongListArgs, + SongListResponse, + SongListSort, + SortOrder, + TopSongListArgs, + UpdatePlaylistArgs, + UpdatePlaylistResponse, + albumArtistListSortMap, + albumListSortMap, + genreListSortMap, + playlistListSortMap, + songListSortMap, + sortOrderMap, } from '/@/renderer/api/types'; -import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; -import { jfNormalize } from './jellyfin-normalize'; -import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types'; -import packageJson from '../../../../package.json'; -import { z } from 'zod'; -import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types'; -import isElectron from 'is-electron'; +import { sortSongList } from '/@/renderer/api/utils'; const formatCommaDelimitedString = (value: string[]) => { return value.join(','); @@ -244,31 +246,56 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { +const getAlbumArtistListCount = async (args: AlbumArtistListArgs): Promise => { const { query, apiClientProps } = args; const res = await jfApiClient(apiClientProps).getAlbumArtistList({ query: { - Limit: query.limit, + Fields: 'Genres, DateCreated, ExternalUrls, Overview', + ImageTypeLimit: 1, + Limit: 1, ParentId: query.musicFolderId, Recursive: true, - SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName', + SearchTerm: query.searchTerm, + SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'Name,SortName', SortOrder: sortOrderMap.jellyfin[query.sortOrder], - StartIndex: query.startIndex, + StartIndex: 0, + UserId: apiClientProps.server?.userId || undefined, }, }); if (res.status !== 200) { - throw new Error('Failed to get artist list'); + throw new Error('Failed to get album artist list count'); } - return { - items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)), - startIndex: query.startIndex, - totalRecordCount: res.body.TotalRecordCount, - }; + return res.body.TotalRecordCount; }; +// const getArtistList = async (args: ArtistListArgs): Promise => { +// const { query, apiClientProps } = args; + +// const res = await jfApiClient(apiClientProps).getAlbumArtistList({ +// query: { +// Limit: query.limit, +// ParentId: query.musicFolderId, +// Recursive: true, +// SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName', +// SortOrder: sortOrderMap.jellyfin[query.sortOrder], +// StartIndex: query.startIndex, +// }, +// }); + +// if (res.status !== 200) { +// throw new Error('Failed to get artist list'); +// } + +// return { +// items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)), +// startIndex: query.startIndex, +// totalRecordCount: res.body.TotalRecordCount, +// }; +// }; + const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { const { query, apiClientProps } = args; @@ -358,6 +385,55 @@ const getAlbumList = async (args: AlbumListArgs): Promise => }; }; +const getAlbumListCount = async (args: AlbumListArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const yearsGroup = []; + if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) { + for ( + let i = Number(query._custom?.jellyfin?.minYear); + i <= Number(query._custom?.jellyfin?.maxYear); + i += 1 + ) { + yearsGroup.push(String(i)); + } + } + + const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined; + + const res = await jfApiClient(apiClientProps).getAlbumList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + AlbumArtistIds: query.artistIds + ? formatCommaDelimitedString(query.artistIds) + : undefined, + ContributingArtistIds: query.isCompilation ? query.artistIds?.[0] : undefined, + IncludeItemTypes: 'MusicAlbum', + Limit: 1, + ParentId: query.musicFolderId, + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: 0, + ...query._custom?.jellyfin, + Years: yearsFilter, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list count'); + } + + return res.body.TotalRecordCount; +}; + const getTopSongList = async (args: TopSongListArgs): Promise => { const { apiClientProps, query } = args; @@ -385,8 +461,11 @@ const getTopSongList = async (args: TopSongListArgs): Promise throw new Error('Failed to get top song list'); } + const songs = res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')); + const songsByPlayCount = sortSongList(songs, SongListSort.PLAY_COUNT, SortOrder.DESC); + return { - items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + items: songsByPlayCount, startIndex: 0, totalRecordCount: res.body.TotalRecordCount, }; @@ -450,6 +529,58 @@ const getSongList = async (args: SongListArgs): Promise => { }; }; +const getSongListCount = async (args: SongListArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const yearsGroup = []; + if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) { + for ( + let i = Number(query._custom?.jellyfin?.minYear); + i <= Number(query._custom?.jellyfin?.maxYear); + i += 1 + ) { + yearsGroup.push(String(i)); + } + } + + const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined; + const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined; + const artistIdsFilter = query.artistIds + ? formatCommaDelimitedString(query.artistIds) + : undefined; + + const res = await jfApiClient(apiClientProps).getSongList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + AlbumIds: albumIdsFilter, + ArtistIds: artistIdsFilter, + Fields: 'Genres, DateCreated, MediaSources, ParentId', + IncludeItemTypes: 'Audio', + Limit: 1, + ParentId: query.musicFolderId, + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: 0, + ...query._custom?.jellyfin, + Years: yearsFilter, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list count'); + } + + return res.body.TotalRecordCount; +}; + const addToPlaylist = async (args: AddToPlaylistArgs): Promise => { const { query, body, apiClientProps } = args; @@ -481,6 +612,7 @@ const removeFromPlaylist = async ( const { query, apiClientProps } = args; const res = await jfApiClient(apiClientProps).removeFromPlaylist({ + body: null, params: { id: query.id, }, @@ -588,6 +720,37 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getPlaylistList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview', + IncludeItemTypes: 'Playlist', + Limit: 1, + MediaTypes: 'Audio', + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: playlistListSortMap.jellyfin[query.sortBy], + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: 0, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist list count'); + } + + return res.body.TotalRecordCount; +}; + const createPlaylist = async (args: CreatePlaylistArgs): Promise => { const { body, apiClientProps } = args; @@ -647,6 +810,7 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { const { query, apiClientProps } = args; const res = await jfApiClient(apiClientProps).deletePlaylist({ + body: null, params: { id: query.id, }, @@ -944,7 +1108,7 @@ const getSongDetail = async (args: SongDetailArgs): Promise return jfNormalize.song(res.body, apiClientProps.server, ''); }; -export const jfController = { +export const JellyfinController: ControllerEndpoint = { addToPlaylist, authenticate, createFavorite, @@ -953,19 +1117,22 @@ export const jfController = { deletePlaylist, getAlbumArtistDetail, getAlbumArtistList, + getAlbumArtistListCount, getAlbumDetail, getAlbumList, - getArtistList, + getAlbumListCount, getGenreList, getLyrics, getMusicFolderList, getPlaylistDetail, getPlaylistList, + getPlaylistListCount, getPlaylistSongList, getRandomSongList, getSongDetail, getSongList, - getTopSongList, + getSongListCount, + getTopSongs: getTopSongList, removeFromPlaylist, scrobble, search, diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 166b23ddc..76b7de40b 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -194,6 +194,27 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getAlbumArtistList({ + query: { + _end: 1, + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: albumArtistListSortMap.navidrome[query.sortBy], + _start: 0, + name: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album artist list count'); + } + + return Number(res.body.headers.get('x-total-count') || 0); +}; + const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { const { query, apiClientProps } = args; @@ -251,6 +272,30 @@ const getAlbumList = async (args: AlbumListArgs): Promise => }; }; +const getAlbumListCount = async (args: AlbumListArgs): Promise => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getAlbumList({ + query: { + _end: 1, + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: albumListSortMap.navidrome[query.sortBy], + _start: 0, + artist_id: query.artistIds?.[0], + compilation: query.isCompilation, + genre_id: query.genre, + name: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list'); + } + + return Number(res.body.headers.get('x-total-count') || 0); +}; + const getSongList = async (args: SongListArgs): Promise => { const { query, apiClientProps } = args; @@ -280,6 +325,29 @@ const getSongList = async (args: SongListArgs): Promise => { }; }; +const getSongListCount = async (args: SongListArgs): Promise => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getSongList({ + query: { + _end: 1, + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: songListSortMap.navidrome[query.sortBy], + _start: 0, + album_artist_id: query.artistIds, + album_id: query.albumIds, + title: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list count'); + } + + return Number(res.body.headers.get('x-total-count') || 0); +}; + const getSongDetail = async (args: SongDetailArgs): Promise => { const { query, apiClientProps } = args; @@ -345,6 +413,7 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getPlaylistList({ + query: { + _end: 1, + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: query.sortBy + ? playlistListSortMap.navidrome[query.sortBy] + : playlistListSortMap.navidrome.name, + _start: 0, + q: query.searchTerm, + ...query._custom?.navidrome, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist list count'); + } + + return Number(res.body.headers.get('x-total-count') || 0); +}; + const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise => { const { query, apiClientProps } = args; @@ -454,6 +546,7 @@ const removeFromPlaylist = async ( const { query, apiClientProps } = args; const res = await ndApiClient(apiClientProps).removeFromPlaylist({ + body: null, params: { id: query.id, }, @@ -479,8 +572,10 @@ export const NavidromeController: ControllerEndpoint = { deletePlaylist, getAlbumArtistDetail, getAlbumArtistList, + getAlbumArtistListCount, getAlbumDetail, getAlbumList, + getAlbumListCount, getArtistDetail: undefined, getArtistInfo: undefined, getFavoritesList: undefined, @@ -491,10 +586,12 @@ export const NavidromeController: ControllerEndpoint = { getMusicFolderList: SubsonicController.getMusicFolderList, getPlaylistDetail, getPlaylistList, + getPlaylistListCount, getPlaylistSongList, getRandomSongList: SubsonicController.getRandomSongList, getSongDetail, getSongList, + getSongListCount, getTopSongs: SubsonicController.getTopSongs, getUserList, removeFromPlaylist, @@ -503,23 +600,3 @@ export const NavidromeController: ControllerEndpoint = { setRating: SubsonicController.setRating, updatePlaylist, }; - -export const ndController = { - addToPlaylist, - authenticate, - createPlaylist, - deletePlaylist, - getAlbumArtistDetail, - getAlbumArtistList, - getAlbumDetail, - getAlbumList, - getGenreList, - getPlaylistDetail, - getPlaylistList, - getPlaylistSongList, - getSongDetail, - getSongList, - getUserList, - removeFromPlaylist, - updatePlaylist, -}; From 4051e9dfa396d96278553a8529d64d7efb611b1b Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 18 Dec 2023 11:46:05 -0800 Subject: [PATCH 19/23] Use imported jellyfin controller --- src/renderer/api/controller.ts | 39 ++-------------------------------- 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index dddd79bb4..294e6b000 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -1,6 +1,6 @@ import { RandomSongListArgs } from './types'; import i18n from '/@/i18n/i18n'; -import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller'; +import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller'; import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller'; import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; import type { @@ -41,42 +41,7 @@ type ApiController = { }; const endpoints: ApiController = { - jellyfin: { - addToPlaylist: jfController.addToPlaylist, - authenticate: jfController.authenticate, - clearPlaylist: undefined, - createFavorite: jfController.createFavorite, - createPlaylist: jfController.createPlaylist, - deleteFavorite: jfController.deleteFavorite, - deletePlaylist: jfController.deletePlaylist, - getAlbumArtistDetail: jfController.getAlbumArtistDetail, - getAlbumArtistList: jfController.getAlbumArtistList, - getAlbumDetail: jfController.getAlbumDetail, - getAlbumList: jfController.getAlbumList, - getArtistDetail: undefined, - getArtistInfo: undefined, - getArtistList: undefined, - getFavoritesList: undefined, - getFolderItemList: undefined, - getFolderList: undefined, - getFolderSongs: undefined, - getGenreList: jfController.getGenreList, - getLyrics: jfController.getLyrics, - getMusicFolderList: jfController.getMusicFolderList, - getPlaylistDetail: jfController.getPlaylistDetail, - getPlaylistList: jfController.getPlaylistList, - getPlaylistSongList: jfController.getPlaylistSongList, - getRandomSongList: jfController.getRandomSongList, - getSongDetail: jfController.getSongDetail, - getSongList: jfController.getSongList, - getTopSongs: jfController.getTopSongList, - getUserList: undefined, - removeFromPlaylist: jfController.removeFromPlaylist, - scrobble: jfController.scrobble, - search: jfController.search, - setRating: undefined, - updatePlaylist: jfController.updatePlaylist, - }, + jellyfin: JellyfinController, navidrome: NavidromeController, subsonic: SubsonicController, }; From f7fcf6c079098e104d2f3398466d89bf24fe7d5c Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 18 Dec 2023 12:02:41 -0800 Subject: [PATCH 20/23] Support subsonic album filters --- .../api/subsonic/subsonic-controller.ts | 192 ++++++++++++++++-- .../api/subsonic/subsonic-normalize.ts | 3 +- src/renderer/api/types.ts | 1 + src/renderer/api/utils.ts | 52 +++++ .../virtual-table/hooks/use-virtual-table.ts | 6 +- .../components/album-list-header-filters.tsx | 63 ++++-- .../components/subsonic-album-filters.tsx | 143 +++++++++++++ .../albums/queries/album-list-count-query.ts | 3 + src/renderer/hooks/use-list-filter-refresh.ts | 28 ++- 9 files changed, 438 insertions(+), 53 deletions(-) create mode 100644 src/renderer/features/albums/components/subsonic-album-filters.tsx diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index c9a54c176..82d3d1540 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1,3 +1,4 @@ +import dayjs from 'dayjs'; import filter from 'lodash/filter'; import orderBy from 'lodash/orderBy'; import md5 from 'md5'; @@ -13,7 +14,7 @@ import { LibraryItem, PlaylistListSort, } from '/@/renderer/api/types'; -import { sortAlbumArtistList, sortSongList } from '/@/renderer/api/utils'; +import { sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/renderer/api/utils'; import { randomString } from '/@/renderer/utils'; const authenticate = async ( @@ -292,6 +293,36 @@ export const SubsonicController: ControllerEndpoint = { getAlbumList: async (args) => { const { query, apiClientProps } = args; + if (query.searchTerm) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: query.limit, + albumOffset: query.startIndex, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 0, + songOffset: 0, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list'); + throw new Error('Failed to get album list'); + } + + const results = + res.body['subsonic-response'].searchResult3.album?.map((album) => + subsonicNormalize.album(album, apiClientProps.server), + ) || []; + + return { + items: results, + startIndex: query.startIndex, + totalRecordCount: null, + }; + } + const sortType: Record = { [AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM, [AlbumListSort.ALBUM_ARTIST]: @@ -312,13 +343,9 @@ export const SubsonicController: ControllerEndpoint = { [AlbumListSort.SONG_COUNT]: undefined, }; - if (query.isCompilation) { - return { - items: [], - startIndex: 0, - totalRecordCount: 0, - }; - } + let type = + sortType[query.sortBy] ?? + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME; if (query.artistIds) { const promises = []; @@ -351,17 +378,63 @@ export const SubsonicController: ControllerEndpoint = { }; } + if (query.isFavorite) { + const res = await subsonicApiClient(apiClientProps).getStarred({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list'); + throw new Error('Failed to get album list'); + } + + const results = + res.body['subsonic-response'].starred.album?.map((album) => + subsonicNormalize.album(album, apiClientProps.server), + ) || []; + + return { + items: sortAlbumList(results, query.sortBy, query.sortOrder), + startIndex: 0, + totalRecordCount: res.body['subsonic-response'].starred.album?.length || 0, + }; + } + + if (query.genre) { + type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_GENRE; + } + + if (query.minYear || query.maxYear) { + type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_YEAR; + } + + let fromYear; + let toYear; + + if (query.minYear) { + fromYear = query.minYear; + toYear = dayjs().year(); + } + + if (query.maxYear) { + toYear = query.maxYear; + + if (!query.minYear) { + fromYear = 0; + } + } + const res = await subsonicApiClient(apiClientProps).getAlbumList2({ query: { - fromYear: query.minYear, + fromYear, genre: query.genre, musicFolderId: query.musicFolderId, offset: query.startIndex, size: query.limit, - toYear: query.maxYear, - type: - sortType[query.sortBy] ?? - SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME, + toYear, + type, }, }); @@ -371,9 +444,10 @@ export const SubsonicController: ControllerEndpoint = { } return { - items: res.body['subsonic-response'].albumList2.album.map((album) => - subsonicNormalize.album(album, apiClientProps.server), - ), + items: + res.body['subsonic-response'].albumList2.album?.map((album) => + subsonicNormalize.album(album, apiClientProps.server, 300), + ) || [], startIndex: query.startIndex, totalRecordCount: null, }; @@ -381,6 +455,41 @@ export const SubsonicController: ControllerEndpoint = { getAlbumListCount: async (args) => { const { query, apiClientProps } = args; + if (query.searchTerm) { + let fetchNextPage = true; + let startIndex = 0; + let totalRecordCount = 0; + + while (fetchNextPage) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: 500, + albumOffset: startIndex, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 0, + songOffset: 0, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list count'); + throw new Error('Failed to get album list count'); + } + + const albumCount = res.body['subsonic-response'].searchResult3.album?.length; + + totalRecordCount += albumCount; + startIndex += albumCount; + + // The max limit size for Subsonic is 500 + fetchNextPage = albumCount === 500; + } + + return totalRecordCount; + } + const sortType: Record = { [AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM, [AlbumListSort.ALBUM_ARTIST]: @@ -401,22 +510,63 @@ export const SubsonicController: ControllerEndpoint = { [AlbumListSort.SONG_COUNT]: undefined, }; + if (query.isFavorite) { + const res = await subsonicApiClient(apiClientProps).getStarred({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list'); + throw new Error('Failed to get album list'); + } + + return res.body['subsonic-response'].starred.album?.length || 0; + } + + let type = + sortType[query.sortBy] ?? + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME; + let fetchNextPage = true; let startIndex = 0; let totalRecordCount = 0; + if (query.genre) { + type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_GENRE; + } + + if (query.minYear || query.maxYear) { + type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_YEAR; + } + + let fromYear; + let toYear; + + if (query.minYear) { + fromYear = query.minYear; + toYear = dayjs().year(); + } + + if (query.maxYear) { + toYear = query.maxYear; + + if (!query.minYear) { + fromYear = 0; + } + } + while (fetchNextPage) { const res = await subsonicApiClient(apiClientProps).getAlbumList2({ query: { - fromYear: query.minYear, + fromYear, genre: query.genre, musicFolderId: query.musicFolderId, offset: startIndex, size: 500, - toYear: query.maxYear, - type: - sortType[query.sortBy] ?? - SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME, + toYear, + type, }, }); diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index ace3974ac..109eb9ee5 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -153,13 +153,14 @@ const normalizeAlbum = ( | z.infer | z.infer, server: ServerListItem | null, + size?: number, ): Album => { const imageUrl = getCoverArtUrl({ baseUrl: server?.url, coverArtId: item.coverArt, credential: server?.credential, - size: 300, + size: size || 300, }) || null; return { diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 0f12aea77..72ae1df0b 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -378,6 +378,7 @@ export type AlbumListQuery = { artistIds?: string[]; genre?: string; isCompilation?: boolean; + isFavorite?: boolean; limit?: number; maxYear?: number; minYear?: number; diff --git a/src/renderer/api/utils.ts b/src/renderer/api/utils.ts index 335c42589..f3a8915c9 100644 --- a/src/renderer/api/utils.ts +++ b/src/renderer/api/utils.ts @@ -3,8 +3,10 @@ import { toast } from '/@/renderer/components'; import { useAuthStore } from '/@/renderer/store'; import { ServerListItem } from '/@/renderer/types'; import { + Album, AlbumArtist, AlbumArtistListSort, + AlbumListSort, QueueSong, SongListSort, SortOrder, @@ -49,6 +51,56 @@ export const authenticationFailure = (currentServer: ServerListItem | null) => { } }; +export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: SortOrder) => { + let results = albums; + + const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; + + switch (sortBy) { + case AlbumListSort.ALBUM_ARTIST: + results = orderBy( + results, + ['albumArtist', (v) => v.name.toLowerCase()], + [order, 'asc'], + ); + break; + case AlbumListSort.DURATION: + results = orderBy(results, ['duration'], [order]); + break; + case AlbumListSort.FAVORITED: + results = orderBy(results, ['starred'], [order]); + break; + case AlbumListSort.NAME: + results = orderBy(results, [(v) => v.name.toLowerCase()], [order]); + break; + case AlbumListSort.PLAY_COUNT: + results = orderBy(results, ['playCount'], [order]); + break; + case AlbumListSort.RANDOM: + results = shuffle(results); + break; + case AlbumListSort.RECENTLY_ADDED: + results = orderBy(results, ['createdAt'], [order]); + break; + case AlbumListSort.RECENTLY_PLAYED: + results = orderBy(results, ['lastPlayedAt'], [order]); + break; + case AlbumListSort.RATING: + results = orderBy(results, ['userRating'], [order]); + break; + case AlbumListSort.YEAR: + results = orderBy(results, ['releaseYear'], [order]); + break; + case AlbumListSort.SONG_COUNT: + results = orderBy(results, ['songCount'], [order]); + break; + default: + break; + } + + return results; +}; + export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder: SortOrder) => { let results = songs; 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 776a6d8c6..7385f3134 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -183,15 +183,14 @@ export const useVirtualTable = ({ } if (results.totalRecordCount === null) { - const totalRecordCount: number | undefined = itemCount; const hasMoreRows = results?.items?.length === properties.filter.limit; const lastRowIndex = hasMoreRows ? undefined - : properties.filter.offset + results.items.length; + : (properties.filter.offset || 0) + results.items.length; params.successCallback( results?.items || [], - totalRecordCount || lastRowIndex, + hasMoreRows ? undefined : lastRowIndex, ); return; } @@ -212,7 +211,6 @@ export const useVirtualTable = ({ queryClient, isClientSideSort, queryFn, - itemCount, ], ); 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 544115578..9fd4eee71 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -22,6 +22,7 @@ import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { useListContext } from '/@/renderer/context/list-context'; import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters'; import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters'; +import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters'; import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared'; import { useContainerQuery } from '/@/renderer/hooks'; import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; @@ -233,27 +234,35 @@ export const AlbumListHeaderFilters = ({ ); 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', }); @@ -389,8 +398,20 @@ export const AlbumListHeaderFilters = ({ filter?._custom?.jellyfin && Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined); - return isNavidromeFilterApplied || isJellyfinFilterApplied; - }, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]); + const isSubsonicFilterApplied = + server?.type === ServerType.SUBSONIC && + (filter.maxYear || filter.minYear || filter.genre || filter.isFavorite); + + return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied; + }, [ + filter?._custom?.jellyfin, + filter?._custom?.navidrome, + filter.genre, + filter.isFavorite, + filter.maxYear, + filter.minYear, + server?.type, + ]); const isFolderFilterApplied = useMemo(() => { return filter.musicFolderId !== undefined; diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx new file mode 100644 index 000000000..976dc42ce --- /dev/null +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -0,0 +1,143 @@ +import { Divider, Group, Stack } from '@mantine/core'; +import debounce from 'lodash/debounce'; +import { ChangeEvent, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { NumberInput, Select, Switch, Text } from '/@/renderer/components'; +import { useGenreList } from '/@/renderer/features/genres'; +import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store'; + +interface SubsonicAlbumFiltersProps { + onFilterChange: (filters: AlbumListFilter) => void; + pageKey: string; + serverId?: string; +} + +export const SubsonicAlbumFilters = ({ + onFilterChange, + pageKey, + serverId, +}: SubsonicAlbumFiltersProps) => { + const { t } = useTranslation(); + const { filter } = useListStoreByKey({ key: pageKey }); + const { setFilter } = useListStoreActions(); + + const genreListQuery = useGenreList({ + query: { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId, + }); + + const genreList = useMemo(() => { + if (!genreListQuery?.data) return []; + return genreListQuery.data.items.map((genre) => ({ + label: genre.name, + value: genre.id, + })); + }, [genreListQuery.data]); + + const handleGenresFilter = debounce((e: string | null) => { + const updatedFilters = setFilter({ + data: { + genre: e || undefined, + }, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + + onFilterChange(updatedFilters); + }, 250); + + const toggleFilters = [ + { + label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), + onChange: (e: ChangeEvent) => { + const updatedFilters = setFilter({ + data: { + isFavorite: e.target.checked ? true : undefined, + }, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + onFilterChange(updatedFilters); + }, + value: filter.isFavorite, + }, + ]; + + const handleYearFilter = debounce((e: number | string, type: 'min' | 'max') => { + let data = {}; + + if (type === 'min') { + data = { + minYear: e || undefined, + }; + } else { + data = { + maxYear: e || undefined, + }; + } + + console.log('data', data); + + const updatedFilters = setFilter({ + data, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + + onFilterChange(updatedFilters); + }, 500); + + return ( + + {toggleFilters.map((filter) => ( + + {filter.label} + + + ))} + + + handleYearFilter(e, 'min')} + /> + handleYearFilter(e, 'max')} + /> + + + + )} + + + ); +}; diff --git a/src/renderer/features/songs/queries/song-list-count-query.ts b/src/renderer/features/songs/queries/song-list-count-query.ts index fe21813ab..a36274c9c 100644 --- a/src/renderer/features/songs/queries/song-list-count-query.ts +++ b/src/renderer/features/songs/queries/song-list-count-query.ts @@ -11,6 +11,8 @@ export const getSongListCountQuery = (query: SongListQuery) => { if (query.searchTerm) filter.searchTerm = query.searchTerm; if (query.genreId) filter.genreId = query.genreId; if (query.musicFolderId) filter.musicFolderId = query.musicFolderId; + if (query.isFavorite) filter.isFavorite = query.isFavorite; + if (query.genre) filter.genre = query.genre; if (Object.keys(filter).length === 0) return undefined; diff --git a/src/renderer/features/songs/routes/song-list-route.tsx b/src/renderer/features/songs/routes/song-list-route.tsx index 3fc974aba..5ffb3d4ca 100644 --- a/src/renderer/features/songs/routes/song-list-route.tsx +++ b/src/renderer/features/songs/routes/song-list-route.tsx @@ -36,6 +36,7 @@ const TrackListRoute = () => { genre_id: genreId, }, }, + genre: genreId, genreId, }), }; diff --git a/src/renderer/hooks/use-list-filter-refresh.ts b/src/renderer/hooks/use-list-filter-refresh.ts index 5df640eb8..a7a0763c4 100644 --- a/src/renderer/hooks/use-list-filter-refresh.ts +++ b/src/renderer/hooks/use-list-filter-refresh.ts @@ -15,6 +15,8 @@ interface UseHandleListFilterChangeProps { server: ServerListItem | null; } +const BLOCK_SIZE = 500; + export const useListFilterRefresh = ({ server, itemType, @@ -108,7 +110,7 @@ export const useListFilterRefresh = ({ } if (results.totalRecordCount === null) { - const hasMoreRows = results?.items?.length === filter.limit; + const hasMoreRows = results?.items?.length === BLOCK_SIZE; const lastRowIndex = hasMoreRows ? undefined : (filter.offset || 0) + results.items.length; From 0cd00329665a1adbab4d53901911cce8468ab336 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 19 Dec 2023 14:59:15 -0800 Subject: [PATCH 22/23] Fix list sort --- src/renderer/api/subsonic/subsonic-controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index d56581f8e..a95a40baa 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -231,7 +231,7 @@ export const SubsonicController: ControllerEndpoint = { } if (query.sortBy) { - sortAlbumArtistList(results, query.sortBy, query.sortOrder); + results = sortAlbumArtistList(results, query.sortBy, query.sortOrder); } return { @@ -818,7 +818,7 @@ export const SubsonicController: ControllerEndpoint = { } if (query.sortBy) { - sortSongList(results, query.sortBy, query.sortOrder); + results = sortSongList(results, query.sortBy, query.sortOrder); } return { From 54b18601b884061e9ac6afc23ef63691d2dde02d Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 19 Dec 2023 14:59:32 -0800 Subject: [PATCH 23/23] Remove playlist detail route file --- .../routes/playlist-detail-route.tsx | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 src/renderer/features/playlists/routes/playlist-detail-route.tsx 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 92a7670b4..000000000 --- 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;