Support entity list pages for subsonic

This commit is contained in:
jeffvli
2023-12-05 18:32:44 -08:00
parent 2ecafea759
commit b2f14d7369
27 changed files with 944 additions and 55 deletions
+40
View File
@@ -131,6 +131,15 @@ const getAlbumList = async (args: AlbumListArgs) => {
)?.(args); )?.(args);
}; };
const getAlbumListCount = async (args: AlbumListArgs) => {
return (
apiController(
'getAlbumListCount',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumListCount']
)?.(args);
};
const getAlbumDetail = async (args: AlbumDetailArgs) => { const getAlbumDetail = async (args: AlbumDetailArgs) => {
return ( return (
apiController( apiController(
@@ -149,6 +158,15 @@ const getSongList = async (args: SongListArgs) => {
)?.(args); )?.(args);
}; };
const getSongListCount = async (args: SongListArgs) => {
return (
apiController(
'getSongListCount',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongListCount']
)?.(args);
};
const getSongDetail = async (args: SongDetailArgs) => { const getSongDetail = async (args: SongDetailArgs) => {
return ( return (
apiController( apiController(
@@ -194,6 +212,15 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
)?.(args); )?.(args);
}; };
const getAlbumArtistListCount = async (args: AlbumArtistListArgs) => {
return (
apiController(
'getAlbumArtistListCount',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistListCount']
)?.(args);
};
const getArtistList = async (args: ArtistListArgs) => { const getArtistList = async (args: ArtistListArgs) => {
return ( return (
apiController( apiController(
@@ -212,6 +239,15 @@ const getPlaylistList = async (args: PlaylistListArgs) => {
)?.(args); )?.(args);
}; };
const getPlaylistListCount = async (args: PlaylistListArgs) => {
return (
apiController(
'getPlaylistListCount',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistListCount']
)?.(args);
};
const createPlaylist = async (args: CreatePlaylistArgs) => { const createPlaylist = async (args: CreatePlaylistArgs) => {
return ( return (
apiController( apiController(
@@ -362,18 +398,22 @@ export const controller = {
deletePlaylist, deletePlaylist,
getAlbumArtistDetail, getAlbumArtistDetail,
getAlbumArtistList, getAlbumArtistList,
getAlbumArtistListCount,
getAlbumDetail, getAlbumDetail,
getAlbumList, getAlbumList,
getAlbumListCount,
getArtistList, getArtistList,
getGenreList, getGenreList,
getLyrics, getLyrics,
getMusicFolderList, getMusicFolderList,
getPlaylistDetail, getPlaylistDetail,
getPlaylistList, getPlaylistList,
getPlaylistListCount,
getPlaylistSongList, getPlaylistSongList,
getRandomSongList, getRandomSongList,
getSongDetail, getSongDetail,
getSongList, getSongList,
getSongListCount,
getTopSongList, getTopSongList,
getUserList, getUserList,
removeFromPlaylist, removeFromPlaylist,
@@ -480,7 +480,6 @@ const removeFromPlaylist = async (
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
const res = await jfApiClient(apiClientProps).removeFromPlaylist({ const res = await jfApiClient(apiClientProps).removeFromPlaylist({
body: null,
params: { params: {
id: query.id, id: query.id,
}, },
@@ -648,7 +647,6 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise<null> => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
const res = await jfApiClient(apiClientProps).deletePlaylist({ const res = await jfApiClient(apiClientProps).deletePlaylist({
body: null,
params: { params: {
id: query.id, id: query.id,
}, },
@@ -11,8 +11,8 @@ import {
import { ServerListItem, ServerType } from '/@/renderer/types'; import { ServerListItem, ServerType } from '/@/renderer/types';
import z from 'zod'; import z from 'zod';
import { ndType } from './navidrome-types'; import { ndType } from './navidrome-types';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { NDGenre } from '/@/renderer/api/navidrome.types'; import { NDGenre } from '/@/renderer/api/navidrome.types';
import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types';
const getImageUrl = (args: { url: string | null }) => { const getImageUrl = (args: { url: string | null }) => {
const { url } = args; const { url } = args;
@@ -186,7 +186,9 @@ const normalizeAlbum = (
const normalizeAlbumArtist = ( const normalizeAlbumArtist = (
item: z.infer<typeof ndType._response.albumArtist> & { item: z.infer<typeof ndType._response.albumArtist> & {
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist']; similarArtists?: z.infer<
typeof SubsonicApi.getArtistInfo2.response
>['subsonic-response']['artistInfo2']['similarArtist'];
}, },
server: ServerListItem | null, server: ServerListItem | null,
): AlbumArtist => { ): AlbumArtist => {
+49 -6
View File
@@ -49,6 +49,19 @@ export const queryKeys: Record<
Record<string, (...props: any) => QueryFunctionContext['queryKey']> Record<string, (...props: any) => QueryFunctionContext['queryKey']>
> = { > = {
albumArtists: { 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) => { detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
if (query) return [serverId, 'albumArtists', 'detail', query] as const; if (query) return [serverId, 'albumArtists', 'detail', query] as const;
return [serverId, 'albumArtists', 'detail'] as const; return [serverId, 'albumArtists', 'detail'] as const;
@@ -72,23 +85,40 @@ export const queryKeys: Record<
}, },
}, },
albums: { albums: {
detail: (serverId: string, query?: AlbumDetailQuery) => count: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
[serverId, 'albums', 'detail', query] as const,
list: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
const { pagination, filter } = splitPaginatedQuery(query); const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination && artistId) { 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) { if (query && pagination) {
return [serverId, 'albums', 'list', filter, pagination] as const; return [serverId, 'albums', 'count', filter, pagination] as const;
} }
if (query && artistId) { 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) { if (query) {
return [serverId, 'albums', 'list', filter] as const; return [serverId, 'albums', 'list', filter] as const;
} }
@@ -207,6 +237,19 @@ export const queryKeys: Record<
root: (serverId: string) => [serverId] as const, root: (serverId: string) => [serverId] as const,
}, },
songs: { 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) => { detail: (serverId: string, query?: SongDetailQuery) => {
if (query) return [serverId, 'songs', 'detail', query] as const; if (query) return [serverId, 'songs', 'detail', query] as const;
return [serverId, 'songs', 'detail'] as const; return [serverId, 'songs', 'detail'] as const;
+12 -7
View File
@@ -435,16 +435,21 @@ axiosClient.interceptors.response.use(
(response) => { (response) => {
const data = response.data; 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 // 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({ toast.error({
message: data['subsonic-response'].error.message, message: data['subsonic-response']?.error.message,
title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string, 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; return response;
@@ -513,9 +518,9 @@ export const subsonicApiClient = (args: {
}); });
return { return {
body: result.data, body: result?.data,
headers: result.headers as any, headers: result?.headers as any,
status: result.status, status: result?.status,
}; };
} catch (e: Error | AxiosError | any) { } catch (e: Error | AxiosError | any) {
if (isAxiosError(e)) { if (isAxiosError(e)) {
@@ -1,15 +1,20 @@
import orderBy from 'lodash/orderBy';
import filter from 'lodash/filter';
import md5 from 'md5'; import md5 from 'md5';
import { fsLog } from '/@/logger';
import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api'; import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { subsonicNormalize } from '/@/renderer/api/subsonic/subsonic-normalize'; import { subsonicNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
import { AlbumListSortType, SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; import { AlbumListSortType, SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types';
import { import {
AlbumArtistListSort,
AlbumListSort, AlbumListSort,
AuthenticationResponse, AuthenticationResponse,
ControllerEndpoint, ControllerEndpoint,
GenreListSort,
LibraryItem, LibraryItem,
PlaylistListSort,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { randomString } from '/@/renderer/utils'; import { randomString } from '/@/renderer/utils';
import { fsLog } from '/@/logger';
const authenticate = async ( const authenticate = async (
url: string, url: string,
@@ -184,7 +189,7 @@ export const SubsonicController: ControllerEndpoint = {
} }
return { return {
...subsonicNormalize.albumArtist(artist, apiClientProps.server), ...subsonicNormalize.albumArtist(artist, apiClientProps.server, 300),
albums: artist.album.map((album) => albums: artist.album.map((album) =>
subsonicNormalize.album(album, apiClientProps.server), subsonicNormalize.album(album, apiClientProps.server),
), ),
@@ -193,6 +198,7 @@ export const SubsonicController: ControllerEndpoint = {
}, },
getAlbumArtistList: async (args) => { getAlbumArtistList: async (args) => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
const res = await subsonicApiClient(apiClientProps).getArtists({ const res = await subsonicApiClient(apiClientProps).getArtists({
query: { query: {
@@ -209,14 +215,79 @@ export const SubsonicController: ControllerEndpoint = {
(index) => index.artist, (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 { return {
items: artists.map((artist) => items: results.map((artist) =>
subsonicNormalize.albumArtist(artist, apiClientProps.server), subsonicNormalize.albumArtist(artist, apiClientProps.server),
), ),
startIndex: query.startIndex, 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) => { getAlbumDetail: async (args) => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
@@ -285,6 +356,73 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount: null, totalRecordCount: null,
}; };
}, },
getAlbumListCount: async (args) => {
const { query, apiClientProps } = args;
const sortType: Record<AlbumListSort, AlbumListSortType | undefined> = {
[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) => { getAlbumSongList: async (args) => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
@@ -326,7 +464,8 @@ export const SubsonicController: ControllerEndpoint = {
return res.body['subsonic-response'].artistInfo; return res.body['subsonic-response'].artistInfo;
}, },
getGenreList: async (args) => { getGenreList: async (args) => {
const { apiClientProps } = args; const { query, apiClientProps } = args;
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
const res = await subsonicApiClient(apiClientProps).getGenres({}); const res = await subsonicApiClient(apiClientProps).getGenres({});
@@ -335,7 +474,31 @@ export const SubsonicController: ControllerEndpoint = {
throw new 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); 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 { return {
items: genres, items: genres,
@@ -361,6 +524,70 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount: res.body['subsonic-response'].musicFolders.musicFolder.length, 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) => { getRandomSongList: async (args) => {
const { query, apiClientProps } = 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) => { getTopSongs: async (args) => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
@@ -8,6 +8,7 @@ import {
Album, Album,
Genre, Genre,
MusicFolder, MusicFolder,
Playlist,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types'; import { ServerListItem, ServerType } from '/@/renderer/types';
@@ -116,13 +117,14 @@ const normalizeAlbumArtist = (
| z.infer<typeof SubsonicApi._baseTypes.artist> | z.infer<typeof SubsonicApi._baseTypes.artist>
| z.infer<typeof SubsonicApi._baseTypes.artistListEntry>, | z.infer<typeof SubsonicApi._baseTypes.artistListEntry>,
server: ServerListItem | null, server: ServerListItem | null,
imageSize?: number,
): AlbumArtist => { ): AlbumArtist => {
const imageUrl = const imageUrl =
getCoverArtUrl({ getCoverArtUrl({
baseUrl: server?.url, baseUrl: server?.url,
coverArtId: item.coverArt, coverArtId: item.coverArt,
credential: server?.credential, credential: server?.credential,
size: 100, size: imageSize || 100,
}) || null; }) || null;
return { return {
@@ -167,7 +169,7 @@ const normalizeAlbum = (
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [], artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
backdropImageUrl: null, backdropImageUrl: null,
createdAt: item.created, createdAt: item.created,
duration: item.duration, duration: item.duration * 1000,
genres: item.genre genres: item.genre
? [ ? [
{ {
@@ -192,7 +194,10 @@ const normalizeAlbum = (
serverType: ServerType.SUBSONIC, serverType: ServerType.SUBSONIC,
size: null, size: null,
songCount: item.songCount, songCount: item.songCount,
songs: [], songs:
(item as z.infer<typeof SubsonicApi._baseTypes.album>).song?.map((song) =>
normalizeSong(song, server, ''),
) || [],
uniqueId: nanoid(), uniqueId: nanoid(),
updatedAt: item.created, updatedAt: item.created,
userFavorite: item.starred || false, userFavorite: item.starred || false,
@@ -220,10 +225,41 @@ const normalizeMusicFolder = (
}; };
}; };
const normalizePlaylist = (
item:
| z.infer<typeof SubsonicApi._baseTypes.playlist>
| z.infer<typeof SubsonicApi._baseTypes.playlistListEntry>,
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 = { export const subsonicNormalize = {
album: normalizeAlbum, album: normalizeAlbum,
albumArtist: normalizeAlbumArtist, albumArtist: normalizeAlbumArtist,
genre: normalizeGenre, genre: normalizeGenre,
musicFolder: normalizeMusicFolder, musicFolder: normalizeMusicFolder,
playlist: normalizePlaylist,
song: normalizeSong, song: normalizeSong,
}; };
+1 -1
View File
@@ -582,7 +582,7 @@ const search3 = {
artistCount: z.number().optional(), artistCount: z.number().optional(),
artistOffset: z.number().optional(), artistOffset: z.number().optional(),
musicFolderId: z.string().optional(), musicFolderId: z.string().optional(),
query: z.string(), query: z.string().or(z.literal('""')),
songCount: z.number().optional(), songCount: z.number().optional(),
songOffset: z.number().optional(), songOffset: z.number().optional(),
}), }),
+13
View File
@@ -306,7 +306,9 @@ export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefine
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs; export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
export enum GenreListSort { export enum GenreListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name', NAME = 'name',
SONG_COUNT = 'songCount',
} }
export type GenreListQuery = { export type GenreListQuery = {
@@ -330,10 +332,14 @@ type GenreListSortMap = {
export const genreListSortMap: GenreListSortMap = { export const genreListSortMap: GenreListSortMap = {
jellyfin: { jellyfin: {
albumCount: undefined,
name: JFGenreListSort.NAME, name: JFGenreListSort.NAME,
songCount: undefined,
}, },
navidrome: { navidrome: {
albumCount: undefined,
name: NDGenreListSort.NAME, name: NDGenreListSort.NAME,
songCount: undefined,
}, },
subsonic: { subsonic: {
name: undefined, name: undefined,
@@ -484,8 +490,11 @@ export type SongListQuery = {
}; };
albumIds?: string[]; albumIds?: string[];
artistIds?: string[]; artistIds?: string[];
genreId?: string;
imageSize?: number; imageSize?: number;
limit?: number; limit?: number;
maxYear?: number;
minYear?: number;
musicFolderId?: string; musicFolderId?: string;
searchTerm?: string; searchTerm?: string;
sortBy: SongListSort; sortBy: SongListSort;
@@ -1161,8 +1170,10 @@ export type ControllerEndpoint = Partial<{
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>; deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>; getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>; getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumArtistListCount: (args: AlbumArtistListArgs) => Promise<number>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>; getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>; getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getAlbumListCount: (args: AlbumListArgs) => Promise<number>;
getAlbumSongList: (args: AlbumDetailArgs) => Promise<SongListResponse>; // TODO getAlbumSongList: (args: AlbumDetailArgs) => Promise<SongListResponse>; // TODO
getArtistDetail: () => void; getArtistDetail: () => void;
getArtistInfo: (args: any) => void; getArtistInfo: (args: any) => void;
@@ -1176,10 +1187,12 @@ export type ControllerEndpoint = Partial<{
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>; getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>; getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>; getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistListCount: (args: PlaylistListArgs) => Promise<number>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>; getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>; getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>; getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>; getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getSongListCount: (args: SongListArgs) => Promise<number>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>; getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<UserListResponse>; getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
@@ -182,6 +182,20 @@ export const useVirtualTable = <TFilter>({
return; 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); params.successCallback(results?.items || [], results?.totalRecordCount || 0);
}, },
rowCount: undefined, rowCount: undefined,
@@ -198,6 +212,7 @@ export const useVirtualTable = <TFilter>({
queryClient, queryClient,
isClientSideSort, isClientSideSort,
queryFn, queryFn,
itemCount,
], ],
); );
@@ -139,14 +139,61 @@ const FILTERS = {
value: AlbumListSort.YEAR, 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 { interface AlbumListHeaderFiltersProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>; gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount: number | undefined;
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => { export const AlbumListHeaderFilters = ({
gridRef,
tableRef,
itemCount,
}: AlbumListHeaderFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { pageKey, customFilters, handlePlay } = useListContext(); const { pageKey, customFilters, handlePlay } = useListContext();
@@ -159,6 +206,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
const cq = useContainerQuery(); const cq = useContainerQuery();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
server, server,
}); });
@@ -38,6 +38,7 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi
const playButtonBehavior = usePlayButtonBehavior(); const playButtonBehavior = usePlayButtonBehavior();
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
server, server,
}); });
@@ -94,6 +95,7 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi
<FilterBar> <FilterBar>
<AlbumListHeaderFilters <AlbumListHeaderFilters
gridRef={gridRef} gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef} tableRef={tableRef}
/> />
</FilterBar> </FilterBar>
@@ -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<string, unknown> = {};
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<AlbumListQuery>) => {
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,
});
};
@@ -9,12 +9,12 @@ import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ListContext } from '/@/renderer/context/list-context'; import { ListContext } from '/@/renderer/context/list-context';
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content'; import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header'; 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 { usePlayQueueAdd } from '/@/renderer/features/player';
import { AnimatedPage } from '/@/renderer/features/shared'; import { AnimatedPage } from '/@/renderer/features/shared';
import { queryClient } from '/@/renderer/lib/react-query'; import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store'; import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
import { Play } from '/@/renderer/types'; import { Play } from '/@/renderer/types';
import { useAlbumListCount } from '/@/renderer/features/albums/queries/album-list-count-query';
const AlbumListRoute = () => { const AlbumListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null); const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
@@ -42,23 +42,18 @@ const AlbumListRoute = () => {
key: pageKey, key: pageKey,
}); });
const itemCountCheck = useAlbumList({ const itemCountCheck = useAlbumListCount({
options: { options: {
cacheTime: 1000 * 60, cacheTime: 1000 * 60,
staleTime: 1000 * 60, staleTime: 1000 * 60,
}, },
query: { query: {
limit: 1,
startIndex: 0,
...albumListFilter, ...albumListFilter,
}, },
serverId: server?.id, serverId: server?.id,
}); });
const itemCount = const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const handlePlay = useCallback( const handlePlay = useCallback(
async (args: { initialSongId?: string; playType: Play }) => { async (args: { initialSongId?: string; playType: Play }) => {
@@ -85,6 +85,28 @@ const FILTERS = {
value: AlbumArtistListSort.SONG_COUNT, 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 { interface AlbumArtistListHeaderFiltersProps {
@@ -35,6 +35,7 @@ export const AlbumArtistListHeader = ({
const cq = useContainerQuery(); const cq = useContainerQuery();
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.ALBUM_ARTIST, itemType: LibraryItem.ALBUM_ARTIST,
server, server,
}); });
@@ -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<string, unknown> = {};
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<AlbumArtistListQuery>) => {
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,
});
};
@@ -7,7 +7,7 @@ import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ListContext } from '/@/renderer/context/list-context'; import { ListContext } from '/@/renderer/context/list-context';
import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content'; import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content';
import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header'; 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'; import { AnimatedPage } from '/@/renderer/features/shared';
const AlbumArtistListRoute = () => { const AlbumArtistListRoute = () => {
@@ -18,23 +18,18 @@ const AlbumArtistListRoute = () => {
const albumArtistListFilter = useListFilterByKey({ key: pageKey }); const albumArtistListFilter = useListFilterByKey({ key: pageKey });
const itemCountCheck = useAlbumArtistList({ const itemCountCheck = useAlbumArtistListCount({
options: { options: {
cacheTime: 1000 * 60, cacheTime: 1000 * 60,
staleTime: 1000 * 60, staleTime: 1000 * 60,
}, },
query: { query: {
limit: 1,
startIndex: 0,
...albumArtistListFilter, ...albumArtistListFilter,
}, },
serverId: server?.id, serverId: server?.id,
}); });
const itemCount = const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const providerValue = useMemo(() => { const providerValue = useMemo(() => {
return { return {
@@ -37,14 +37,36 @@ const FILTERS = {
value: GenreListSort.NAME, 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 { interface GenreListHeaderFiltersProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>; gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount: number | undefined;
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => { export const GenreListHeaderFilters = ({
gridRef,
tableRef,
itemCount,
}: GenreListHeaderFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { pageKey, customFilters } = useListContext(); const { pageKey, customFilters } = useListContext();
@@ -54,6 +76,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
const cq = useContainerQuery(); const cq = useContainerQuery();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.GENRE, itemType: LibraryItem.GENRE,
server, server,
}); });
@@ -34,6 +34,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
const { setFilter, setTablePagination } = useListStoreActions(); const { setFilter, setTablePagination } = useListStoreActions();
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.GENRE, itemType: LibraryItem.GENRE,
server, server,
}); });
@@ -89,6 +90,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
<FilterBar> <FilterBar>
<GenreListHeaderFilters <GenreListHeaderFilters
gridRef={gridRef} gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef} tableRef={tableRef}
/> />
</FilterBar> </FilterBar>
@@ -69,6 +69,38 @@ const FILTERS = {
value: PlaylistListSort.UPDATED_AT, 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 { interface PlaylistListHeaderFiltersProps {
@@ -44,6 +44,7 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
}; };
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.PLAYLIST, itemType: LibraryItem.PLAYLIST,
server, server,
}); });
@@ -160,14 +160,26 @@ const FILTERS = {
value: SongListSort.YEAR, value: SongListSort.YEAR,
}, },
], ],
subsonic: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: SongListSort.NAME,
},
],
}; };
interface SongListHeaderFiltersProps { interface SongListHeaderFiltersProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>; gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount: number | undefined;
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFiltersProps) => { export const SongListHeaderFilters = ({
gridRef,
tableRef,
itemCount,
}: SongListHeaderFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const server = useCurrentServer(); const server = useCurrentServer();
const { pageKey, handlePlay, customFilters } = useListContext(); const { pageKey, handlePlay, customFilters } = useListContext();
@@ -179,6 +191,7 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
useListStoreActions(); useListStoreActions();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
server, server,
}); });
@@ -32,6 +32,7 @@ export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongList
const cq = useContainerQuery(); const cq = useContainerQuery();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
server, server,
}); });
@@ -94,6 +95,7 @@ export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongList
<FilterBar> <FilterBar>
<SongListHeaderFilters <SongListHeaderFilters
gridRef={gridRef} gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef} tableRef={tableRef}
/> />
</FilterBar> </FilterBar>
@@ -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<string, unknown> = {};
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<SongListQuery>) => {
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,
});
};
@@ -9,11 +9,11 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AnimatedPage } from '/@/renderer/features/shared'; import { AnimatedPage } from '/@/renderer/features/shared';
import { SongListContent } from '/@/renderer/features/songs/components/song-list-content'; import { SongListContent } from '/@/renderer/features/songs/components/song-list-content';
import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header'; 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 { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
import { Play } from '/@/renderer/types'; import { Play } from '/@/renderer/types';
import { titleCase } from '/@/renderer/utils'; import { titleCase } from '/@/renderer/utils';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { useSongListCount } from '/@/renderer/features/songs/queries/song-list-count-query';
const TrackListRoute = () => { const TrackListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null); const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
@@ -36,6 +36,7 @@ const TrackListRoute = () => {
genre_id: genreId, genre_id: genreId,
}, },
}, },
genreId,
}), }),
}; };
@@ -74,7 +75,7 @@ const TrackListRoute = () => {
return genre?.name; return genre?.name;
}, [genreId, genreList.data]); }, [genreId, genreList.data]);
const itemCountCheck = useSongList({ const itemCountCheck = useSongListCount({
options: { options: {
cacheTime: 1000 * 60, cacheTime: 1000 * 60,
staleTime: 1000 * 60, staleTime: 1000 * 60,
@@ -87,10 +88,7 @@ const TrackListRoute = () => {
serverId: server?.id, serverId: server?.id,
}); });
const itemCount = const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const handlePlay = useCallback( const handlePlay = useCallback(
async (args: { initialSongId?: string; playType: Play }) => { async (args: { initialSongId?: string; playType: Play }) => {
@@ -10,6 +10,7 @@ import orderBy from 'lodash/orderBy';
interface UseHandleListFilterChangeProps { interface UseHandleListFilterChangeProps {
isClientSideSort?: boolean; isClientSideSort?: boolean;
itemCount?: number;
itemType: LibraryItem; itemType: LibraryItem;
server: ServerListItem | null; server: ServerListItem | null;
} }
@@ -18,6 +19,7 @@ export const useListFilterRefresh = ({
server, server,
itemType, itemType,
isClientSideSort, isClientSideSort,
itemCount,
}: UseHandleListFilterChangeProps) => { }: UseHandleListFilterChangeProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -98,11 +100,14 @@ export const useListFilterRefresh = ({
filter.sortOrder === 'DESC' ? ['desc'] : ['asc'], filter.sortOrder === 'DESC' ? ['desc'] : ['asc'],
); );
params.successCallback(sortedResults || [], res?.totalRecordCount || 0); params.successCallback(
sortedResults || [],
res?.totalRecordCount || itemCount,
);
return; return;
} }
params.successCallback(res?.items || [], res?.totalRecordCount || 0); params.successCallback(res?.items || [], res?.totalRecordCount || itemCount);
}, },
rowCount: undefined, rowCount: undefined,
@@ -112,7 +117,7 @@ export const useListFilterRefresh = ({
tableRef.current?.api.purgeInfiniteCache(); tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top'); tableRef.current?.api.ensureIndexVisible(0, 'top');
}, },
[isClientSideSort, queryClient, queryFn, queryKeyFn, server], [isClientSideSort, itemCount, queryClient, queryFn, queryKeyFn, server],
); );
const handleRefreshGrid = useCallback( const handleRefreshGrid = useCallback(