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

View File

@ -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,

View File

@ -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<null> => {
const { query, apiClientProps } = args;
const res = await jfApiClient(apiClientProps).deletePlaylist({
body: null,
params: {
id: query.id,
},

View File

@ -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<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,
): AlbumArtist => {

View File

@ -49,6 +49,19 @@ export const queryKeys: Record<
Record<string, (...props: any) => 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;

View File

@ -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)) {

View File

@ -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, 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) => {
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;

View File

@ -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<typeof SubsonicApi._baseTypes.artist>
| z.infer<typeof SubsonicApi._baseTypes.artistListEntry>,
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<typeof SubsonicApi._baseTypes.album>).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<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 = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
genre: normalizeGenre,
musicFolder: normalizeMusicFolder,
playlist: normalizePlaylist,
song: normalizeSong,
};

View File

@ -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(),
}),

View File

@ -306,7 +306,9 @@ export type GenreListResponse = BasePaginatedResponse<Genre[]> | 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<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumArtistListCount: (args: AlbumArtistListArgs) => Promise<number>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getAlbumListCount: (args: AlbumListArgs) => Promise<number>;
getAlbumSongList: (args: AlbumDetailArgs) => Promise<SongListResponse>; // TODO
getArtistDetail: () => void;
getArtistInfo: (args: any) => void;
@ -1176,10 +1187,12 @@ export type ControllerEndpoint = Partial<{
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistListCount: (args: PlaylistListArgs) => Promise<number>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getSongListCount: (args: SongListArgs) => Promise<number>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;

View File

@ -182,6 +182,20 @@ export const useVirtualTable = <TFilter>({
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 = <TFilter>({
queryClient,
isClientSideSort,
queryFn,
itemCount,
],
);

View File

@ -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<VirtualInfiniteGridRef | null>;
itemCount: number | undefined;
tableRef: MutableRefObject<AgGridReactType | null>;
}
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,
});

View File

@ -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
<FilterBar>
<AlbumListHeaderFilters
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</FilterBar>

View File

@ -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,
});
};

View File

@ -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<VirtualInfiniteGridRef | null>(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 }) => {

View File

@ -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 {

View File

@ -35,6 +35,7 @@ export const AlbumArtistListHeader = ({
const cq = useContainerQuery();
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.ALBUM_ARTIST,
server,
});

View File

@ -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,
});
};

View File

@ -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 {

View File

@ -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<VirtualInfiniteGridRef | null>;
itemCount: number | undefined;
tableRef: MutableRefObject<AgGridReactType | null>;
}
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,
});

View File

@ -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
<FilterBar>
<GenreListHeaderFilters
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</FilterBar>

View File

@ -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 {

View File

@ -44,6 +44,7 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
};
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.PLAYLIST,
server,
});

View File

@ -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<VirtualInfiniteGridRef | null>;
itemCount: number | undefined;
tableRef: MutableRefObject<AgGridReactType | null>;
}
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,
});

View File

@ -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
<FilterBar>
<SongListHeaderFilters
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</FilterBar>

View File

@ -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,
});
};

View File

@ -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<VirtualInfiniteGridRef | null>(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 }) => {

View File

@ -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(