Subsonic 2, general rework (#758)

This commit is contained in:
Kendall Garner 2024-09-26 04:23:08 +00:00 committed by GitHub
parent 31492fa9ef
commit 8cddbef701
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 4625 additions and 3566 deletions

View file

@ -1,119 +1,11 @@
import { useAuthStore } from '/@/renderer/store'; import { useAuthStore } from '/@/renderer/store';
import { toast } from '/@/renderer/components/toast/index'; import { toast } from '/@/renderer/components/toast/index';
import type { import type { ServerType, ControllerEndpoint, AuthenticationResponse } from '/@/renderer/api/types';
AlbumDetailArgs, import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller';
AlbumListArgs, import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
SongListArgs, import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller';
SongDetailArgs,
AlbumArtistDetailArgs,
AlbumArtistListArgs,
SetRatingArgs,
ShareItemArgs,
GenreListArgs,
CreatePlaylistArgs,
DeletePlaylistArgs,
PlaylistDetailArgs,
PlaylistListArgs,
MusicFolderListArgs,
PlaylistSongListArgs,
ArtistListArgs,
UpdatePlaylistArgs,
UserListArgs,
FavoriteArgs,
TopSongListArgs,
AddToPlaylistArgs,
AddToPlaylistResponse,
RemoveFromPlaylistArgs,
RemoveFromPlaylistResponse,
ScrobbleArgs,
ScrobbleResponse,
AlbumArtistDetailResponse,
FavoriteResponse,
CreatePlaylistResponse,
AlbumArtistListResponse,
AlbumDetailResponse,
AlbumListResponse,
ArtistListResponse,
GenreListResponse,
MusicFolderListResponse,
PlaylistDetailResponse,
PlaylistListResponse,
RatingResponse,
SongDetailResponse,
SongListResponse,
TopSongListResponse,
UpdatePlaylistResponse,
UserListResponse,
AuthenticationResponse,
SearchArgs,
SearchResponse,
LyricsArgs,
LyricsResponse,
ServerInfo,
ServerInfoArgs,
StructuredLyricsArgs,
StructuredLyric,
SimilarSongsArgs,
Song,
ServerType,
ShareItemResponse,
MoveItemArgs,
DownloadArgs,
TranscodingArgs,
} from '/@/renderer/api/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
import i18n from '/@/i18n/i18n'; import i18n from '/@/i18n/i18n';
export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
authenticate: (
url: string,
body: { password: string; username: string },
) => Promise<AuthenticationResponse>;
clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getArtistDetail: () => void;
getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getDownloadUrl: (args: DownloadArgs) => string;
getFavoritesList: () => void;
getFolderItemList: () => void;
getFolderList: () => void;
getFolderSongs: () => void;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getStructuredLyrics: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getTranscodingUrl: (args: TranscodingArgs) => string;
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
movePlaylistItem: (args: MoveItemArgs) => Promise<void>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
shareItem: (args: ShareItemArgs) => Promise<ShareItemResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
}>;
type ApiController = { type ApiController = {
jellyfin: ControllerEndpoint; jellyfin: ControllerEndpoint;
navidrome: ControllerEndpoint; navidrome: ControllerEndpoint;
@ -121,133 +13,15 @@ type ApiController = {
}; };
const endpoints: ApiController = { const endpoints: ApiController = {
jellyfin: { jellyfin: JellyfinController,
addToPlaylist: jfController.addToPlaylist, navidrome: NavidromeController,
authenticate: jfController.authenticate, subsonic: SubsonicController,
clearPlaylist: undefined,
createFavorite: jfController.createFavorite,
createPlaylist: jfController.createPlaylist,
deleteFavorite: jfController.deleteFavorite,
deletePlaylist: jfController.deletePlaylist,
getAlbumArtistDetail: jfController.getAlbumArtistDetail,
getAlbumArtistList: jfController.getAlbumArtistList,
getAlbumDetail: jfController.getAlbumDetail,
getAlbumList: jfController.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getDownloadUrl: jfController.getDownloadUrl,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: jfController.getGenreList,
getLyrics: jfController.getLyrics,
getMusicFolderList: jfController.getMusicFolderList,
getPlaylistDetail: jfController.getPlaylistDetail,
getPlaylistList: jfController.getPlaylistList,
getPlaylistSongList: jfController.getPlaylistSongList,
getRandomSongList: jfController.getRandomSongList,
getServerInfo: jfController.getServerInfo,
getSimilarSongs: jfController.getSimilarSongs,
getSongDetail: jfController.getSongDetail,
getSongList: jfController.getSongList,
getStructuredLyrics: undefined,
getTopSongs: jfController.getTopSongList,
getTranscodingUrl: jfController.getTranscodingUrl,
getUserList: undefined,
movePlaylistItem: jfController.movePlaylistItem,
removeFromPlaylist: jfController.removeFromPlaylist,
scrobble: jfController.scrobble,
search: jfController.search,
setRating: undefined,
shareItem: undefined,
updatePlaylist: jfController.updatePlaylist,
},
navidrome: {
addToPlaylist: ndController.addToPlaylist,
authenticate: ndController.authenticate,
clearPlaylist: undefined,
createFavorite: ssController.createFavorite,
createPlaylist: ndController.createPlaylist,
deleteFavorite: ssController.removeFavorite,
deletePlaylist: ndController.deletePlaylist,
getAlbumArtistDetail: ndController.getAlbumArtistDetail,
getAlbumArtistList: ndController.getAlbumArtistList,
getAlbumDetail: ndController.getAlbumDetail,
getAlbumList: ndController.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getDownloadUrl: ssController.getDownloadUrl,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: ndController.getGenreList,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: ndController.getPlaylistDetail,
getPlaylistList: ndController.getPlaylistList,
getPlaylistSongList: ndController.getPlaylistSongList,
getRandomSongList: ssController.getRandomSongList,
getServerInfo: ndController.getServerInfo,
getSimilarSongs: ndController.getSimilarSongs,
getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList,
getStructuredLyrics: ssController.getStructuredLyrics,
getTopSongs: ssController.getTopSongList,
getTranscodingUrl: ssController.getTranscodingUrl,
getUserList: ndController.getUserList,
movePlaylistItem: ndController.movePlaylistItem,
removeFromPlaylist: ndController.removeFromPlaylist,
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: ssController.setRating,
shareItem: ndController.shareItem,
updatePlaylist: ndController.updatePlaylist,
},
subsonic: {
authenticate: ssController.authenticate,
clearPlaylist: undefined,
createFavorite: ssController.createFavorite,
createPlaylist: undefined,
deleteFavorite: ssController.removeFavorite,
deletePlaylist: undefined,
getAlbumArtistDetail: undefined,
getAlbumArtistList: undefined,
getAlbumDetail: undefined,
getAlbumList: undefined,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getDownloadUrl: ssController.getDownloadUrl,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: undefined,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: undefined,
getPlaylistList: undefined,
getServerInfo: ssController.getServerInfo,
getSimilarSongs: ssController.getSimilarSongs,
getSongDetail: undefined,
getSongList: undefined,
getStructuredLyrics: ssController.getStructuredLyrics,
getTopSongs: ssController.getTopSongList,
getTranscodingUrl: ssController.getTranscodingUrl,
getUserList: undefined,
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: undefined,
shareItem: undefined,
updatePlaylist: undefined,
},
}; };
const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => { const apiController = <K extends keyof ControllerEndpoint>(
endpoint: K,
type?: ServerType,
): NonNullable<ControllerEndpoint[K]> => {
const serverType = type || useAuthStore.getState().currentServer?.type; const serverType = type || useAuthStore.getState().currentServer?.type;
if (!serverType) { if (!serverType) {
@ -277,344 +51,127 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) =>
); );
} }
return endpoints[serverType][endpoint]; return controllerFn;
}; };
const authenticate = async ( export interface GeneralController extends Omit<Required<ControllerEndpoint>, 'authenticate'> {
url: string, authenticate: (
body: { legacy?: boolean; password: string; username: string }, url: string,
type: ServerType, body: { legacy?: boolean; password: string; username: string },
) => { type: ServerType,
return (apiController('authenticate', type) as ControllerEndpoint['authenticate'])?.(url, body); ) => Promise<AuthenticationResponse>;
}; }
const getAlbumList = async (args: AlbumListArgs) => { export const controller: GeneralController = {
return ( addToPlaylist(args) {
apiController( return apiController('addToPlaylist', args.apiClientProps.server?.type)?.(args);
'getAlbumList', },
args.apiClientProps.server?.type, authenticate(url, body, type) {
) as ControllerEndpoint['getAlbumList'] return apiController('authenticate', type)(url, body);
)?.(args); },
}; createFavorite(args) {
return apiController('createFavorite', args.apiClientProps.server?.type)?.(args);
const getAlbumDetail = async (args: AlbumDetailArgs) => { },
return ( createPlaylist(args) {
apiController( return apiController('createPlaylist', args.apiClientProps.server?.type)?.(args);
'getAlbumDetail', },
args.apiClientProps.server?.type, deleteFavorite(args) {
) as ControllerEndpoint['getAlbumDetail'] return apiController('deleteFavorite', args.apiClientProps.server?.type)?.(args);
)?.(args); },
}; deletePlaylist(args) {
return apiController('deletePlaylist', args.apiClientProps.server?.type)?.(args);
const getSongList = async (args: SongListArgs) => { },
return ( getAlbumArtistDetail(args) {
apiController( return apiController('getAlbumArtistDetail', args.apiClientProps.server?.type)?.(args);
'getSongList', },
args.apiClientProps.server?.type, getAlbumArtistList(args) {
) as ControllerEndpoint['getSongList'] return apiController('getAlbumArtistList', args.apiClientProps.server?.type)?.(args);
)?.(args); },
}; getAlbumArtistListCount(args) {
return apiController('getAlbumArtistListCount', args.apiClientProps.server?.type)?.(args);
const getSongDetail = async (args: SongDetailArgs) => { },
return ( getAlbumDetail(args) {
apiController( return apiController('getAlbumDetail', args.apiClientProps.server?.type)?.(args);
'getSongDetail', },
args.apiClientProps.server?.type, getAlbumList(args) {
) as ControllerEndpoint['getSongDetail'] return apiController('getAlbumList', args.apiClientProps.server?.type)?.(args);
)?.(args); },
}; getAlbumListCount(args) {
return apiController('getAlbumListCount', args.apiClientProps.server?.type)?.(args);
const getMusicFolderList = async (args: MusicFolderListArgs) => { },
return ( getDownloadUrl(args) {
apiController( return apiController('getDownloadUrl', args.apiClientProps.server?.type)?.(args);
'getMusicFolderList', },
args.apiClientProps.server?.type, getGenreList(args) {
) as ControllerEndpoint['getMusicFolderList'] return apiController('getGenreList', args.apiClientProps.server?.type)?.(args);
)?.(args); },
}; getLyrics(args) {
return apiController('getLyrics', args.apiClientProps.server?.type)?.(args);
const getGenreList = async (args: GenreListArgs) => { },
return ( getMusicFolderList(args) {
apiController( return apiController('getMusicFolderList', args.apiClientProps.server?.type)?.(args);
'getGenreList', },
args.apiClientProps.server?.type, getPlaylistDetail(args) {
) as ControllerEndpoint['getGenreList'] return apiController('getPlaylistDetail', args.apiClientProps.server?.type)?.(args);
)?.(args); },
}; getPlaylistList(args) {
return apiController('getPlaylistList', args.apiClientProps.server?.type)?.(args);
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs) => { },
return ( getPlaylistListCount(args) {
apiController( return apiController('getPlaylistListCount', args.apiClientProps.server?.type)?.(args);
'getAlbumArtistDetail', },
args.apiClientProps.server?.type, getPlaylistSongList(args) {
) as ControllerEndpoint['getAlbumArtistDetail'] return apiController('getPlaylistSongList', args.apiClientProps.server?.type)?.(args);
)?.(args); },
}; getRandomSongList(args) {
return apiController('getRandomSongList', args.apiClientProps.server?.type)?.(args);
const getAlbumArtistList = async (args: AlbumArtistListArgs) => { },
return ( getServerInfo(args) {
apiController( return apiController('getServerInfo', args.apiClientProps.server?.type)?.(args);
'getAlbumArtistList', },
args.apiClientProps.server?.type, getSimilarSongs(args) {
) as ControllerEndpoint['getAlbumArtistList'] return apiController('getSimilarSongs', args.apiClientProps.server?.type)?.(args);
)?.(args); },
}; getSongDetail(args) {
return apiController('getSongDetail', args.apiClientProps.server?.type)?.(args);
const getArtistList = async (args: ArtistListArgs) => { },
return ( getSongList(args) {
apiController( return apiController('getSongList', args.apiClientProps.server?.type)?.(args);
'getArtistList', },
args.apiClientProps.server?.type, getSongListCount(args) {
) as ControllerEndpoint['getArtistList'] return apiController('getSongListCount', args.apiClientProps.server?.type)?.(args);
)?.(args); },
}; getStructuredLyrics(args) {
return apiController('getStructuredLyrics', args.apiClientProps.server?.type)?.(args);
const getPlaylistList = async (args: PlaylistListArgs) => { },
return ( getTopSongs(args) {
apiController( return apiController('getTopSongs', args.apiClientProps.server?.type)?.(args);
'getPlaylistList', },
args.apiClientProps.server?.type, getTranscodingUrl(args) {
) as ControllerEndpoint['getPlaylistList'] return apiController('getTranscodingUrl', args.apiClientProps.server?.type)?.(args);
)?.(args); },
}; getUserList(args) {
return apiController('getUserList', args.apiClientProps.server?.type)?.(args);
const createPlaylist = async (args: CreatePlaylistArgs) => { },
return ( movePlaylistItem(args) {
apiController( return apiController('movePlaylistItem', args.apiClientProps.server?.type)?.(args);
'createPlaylist', },
args.apiClientProps.server?.type, removeFromPlaylist(args) {
) as ControllerEndpoint['createPlaylist'] return apiController('removeFromPlaylist', args.apiClientProps.server?.type)?.(args);
)?.(args); },
}; scrobble(args) {
return apiController('scrobble', args.apiClientProps.server?.type)?.(args);
const updatePlaylist = async (args: UpdatePlaylistArgs) => { },
return ( search(args) {
apiController( return apiController('search', args.apiClientProps.server?.type)?.(args);
'updatePlaylist', },
args.apiClientProps.server?.type, setRating(args) {
) as ControllerEndpoint['updatePlaylist'] return apiController('setRating', args.apiClientProps.server?.type)?.(args);
)?.(args); },
}; shareItem(args) {
return apiController('shareItem', args.apiClientProps.server?.type)?.(args);
const deletePlaylist = async (args: DeletePlaylistArgs) => { },
return ( updatePlaylist(args) {
apiController( return apiController('updatePlaylist', args.apiClientProps.server?.type)?.(args);
'deletePlaylist', },
args.apiClientProps.server?.type,
) as ControllerEndpoint['deletePlaylist']
)?.(args);
};
const addToPlaylist = async (args: AddToPlaylistArgs) => {
return (
apiController(
'addToPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['addToPlaylist']
)?.(args);
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
return (
apiController(
'removeFromPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['removeFromPlaylist']
)?.(args);
};
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
return (
apiController(
'getPlaylistDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistDetail']
)?.(args);
};
const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
return (
apiController(
'getPlaylistSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistSongList']
)?.(args);
};
const getUserList = async (args: UserListArgs) => {
return (
apiController(
'getUserList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getUserList']
)?.(args);
};
const createFavorite = async (args: FavoriteArgs) => {
return (
apiController(
'createFavorite',
args.apiClientProps.server?.type,
) as ControllerEndpoint['createFavorite']
)?.(args);
};
const deleteFavorite = async (args: FavoriteArgs) => {
return (
apiController(
'deleteFavorite',
args.apiClientProps.server?.type,
) as ControllerEndpoint['deleteFavorite']
)?.(args);
};
const updateRating = async (args: SetRatingArgs) => {
return (
apiController(
'setRating',
args.apiClientProps.server?.type,
) as ControllerEndpoint['setRating']
)?.(args);
};
const shareItem = async (args: ShareItemArgs) => {
return (
apiController(
'shareItem',
args.apiClientProps.server?.type,
) as ControllerEndpoint['shareItem']
)?.(args);
};
const getTopSongList = async (args: TopSongListArgs) => {
return (
apiController(
'getTopSongs',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getTopSongs']
)?.(args);
};
const scrobble = async (args: ScrobbleArgs) => {
return (
apiController(
'scrobble',
args.apiClientProps.server?.type,
) as ControllerEndpoint['scrobble']
)?.(args);
};
const search = async (args: SearchArgs) => {
return (
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
)?.(args);
};
const getRandomSongList = async (args: RandomSongListArgs) => {
return (
apiController(
'getRandomSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getRandomSongList']
)?.(args);
};
const getLyrics = async (args: LyricsArgs) => {
return (
apiController(
'getLyrics',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getLyrics']
)?.(args);
};
const getServerInfo = async (args: ServerInfoArgs) => {
return (
apiController(
'getServerInfo',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getServerInfo']
)?.(args);
};
const getStructuredLyrics = async (args: StructuredLyricsArgs) => {
return (
apiController(
'getStructuredLyrics',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getStructuredLyrics']
)?.(args);
};
const getSimilarSongs = async (args: SimilarSongsArgs) => {
return (
apiController(
'getSimilarSongs',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSimilarSongs']
)?.(args);
};
const movePlaylistItem = async (args: MoveItemArgs) => {
return (
apiController(
'movePlaylistItem',
args.apiClientProps.server?.type,
) as ControllerEndpoint['movePlaylistItem']
)?.(args);
};
const getDownloadUrl = (args: DownloadArgs) => {
return (
apiController(
'getDownloadUrl',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getDownloadUrl']
)?.(args);
};
const getTranscodingUrl = (args: TranscodingArgs) => {
return (
apiController(
'getTranscodingUrl',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getTranscodingUrl']
)?.(args);
};
export const controller = {
addToPlaylist,
authenticate,
createFavorite,
createPlaylist,
deleteFavorite,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getArtistList,
getDownloadUrl,
getGenreList,
getLyrics,
getMusicFolderList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getRandomSongList,
getServerInfo,
getSimilarSongs,
getSongDetail,
getSongList,
getStructuredLyrics,
getTopSongList,
getTranscodingUrl,
getUserList,
movePlaylistItem,
removeFromPlaylist,
scrobble,
search,
shareItem,
updatePlaylist,
updateRating,
}; };

File diff suppressed because it is too large Load diff

View file

@ -237,7 +237,7 @@ export enum NDSongListSort {
CHANNELS = 'channels', CHANNELS = 'channels',
COMMENT = 'comment', COMMENT = 'comment',
DURATION = 'duration', DURATION = 'duration',
FAVORITED = 'starred', FAVORITED = 'starred_at',
GENRE = 'genre', GENRE = 'genre',
ID = 'id', ID = 'id',
PLAY_COUNT = 'playCount', PLAY_COUNT = 'playCount',

File diff suppressed because it is too large Load diff

View file

@ -224,7 +224,7 @@ const songListParameters = paginationParameters.extend({
album_artist_id: z.array(z.string()).optional(), album_artist_id: z.array(z.string()).optional(),
album_id: z.array(z.string()).optional(), album_id: z.array(z.string()).optional(),
artist_id: z.array(z.string()).optional(), artist_id: z.array(z.string()).optional(),
genre_id: z.string().optional(), genre_id: z.array(z.string()).optional(),
path: z.string().optional(), path: z.string().optional(),
starred: z.boolean().optional(), starred: z.boolean().optional(),
title: z.string().optional(), title: z.string().optional(),

View file

@ -50,6 +50,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;
@ -73,6 +86,27 @@ export const queryKeys: Record<
}, },
}, },
albums: { albums: {
count: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination && artistId) {
return [serverId, 'albums', 'count', artistId, filter, pagination] as const;
}
if (query && pagination) {
return [serverId, 'albums', 'count', filter, pagination] as const;
}
if (query && artistId) {
return [serverId, 'albums', 'count', artistId, filter] as const;
}
if (query) {
return [serverId, 'albums', 'count', filter] as const;
}
return [serverId, 'albums', 'count'] as const;
},
detail: (serverId: string, query?: AlbumDetailQuery) => detail: (serverId: string, query?: AlbumDetailQuery) =>
[serverId, 'albums', 'detail', query] as const, [serverId, 'albums', 'detail', query] as const,
list: (serverId: string, query?: AlbumListQuery, artistId?: string) => { list: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
@ -208,6 +242,18 @@ export const queryKeys: Record<
root: (serverId: string) => [serverId] as const, root: (serverId: string) => [serverId] as const,
}, },
songs: { songs: {
count: (serverId: string, query?: SongListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'songs', 'count', filter, pagination] as const;
}
if (query) {
return [serverId, 'songs', 'count', filter] as const;
}
return [serverId, 'songs', 'count'] as const;
},
detail: (serverId: string, query?: SongDetailQuery) => { 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;

View file

@ -27,6 +27,46 @@ export const contract = c.router({
200: ssType._response.createFavorite, 200: ssType._response.createFavorite,
}, },
}, },
createPlaylist: {
method: 'GET',
path: 'createPlaylist.view',
query: ssType._parameters.createPlaylist,
responses: {
200: ssType._response.createPlaylist,
},
},
deletePlaylist: {
method: 'GET',
path: 'deletePlaylist.view',
query: ssType._parameters.deletePlaylist,
responses: {
200: ssType._response.baseResponse,
},
},
getAlbum: {
method: 'GET',
path: 'getAlbum.view',
query: ssType._parameters.getAlbum,
responses: {
200: ssType._response.getAlbum,
},
},
getAlbumList2: {
method: 'GET',
path: 'getAlbumList2.view',
query: ssType._parameters.getAlbumList2,
responses: {
200: ssType._response.getAlbumList2,
},
},
getArtist: {
method: 'GET',
path: 'getArtist.view',
query: ssType._parameters.getArtist,
responses: {
200: ssType._response.getArtist,
},
},
getArtistInfo: { getArtistInfo: {
method: 'GET', method: 'GET',
path: 'getArtistInfo.view', path: 'getArtistInfo.view',
@ -35,6 +75,22 @@ export const contract = c.router({
200: ssType._response.artistInfo, 200: ssType._response.artistInfo,
}, },
}, },
getArtists: {
method: 'GET',
path: 'getArtists.view',
query: ssType._parameters.getArtists,
responses: {
200: ssType._response.getArtists,
},
},
getGenres: {
method: 'GET',
path: 'getGenres.view',
query: ssType._parameters.getGenres,
responses: {
200: ssType._response.getGenres,
},
},
getMusicFolderList: { getMusicFolderList: {
method: 'GET', method: 'GET',
path: 'getMusicFolders.view', path: 'getMusicFolders.view',
@ -42,6 +98,22 @@ export const contract = c.router({
200: ssType._response.musicFolderList, 200: ssType._response.musicFolderList,
}, },
}, },
getPlaylist: {
method: 'GET',
path: 'getPlaylist.view',
query: ssType._parameters.getPlaylist,
responses: {
200: ssType._response.getPlaylist,
},
},
getPlaylists: {
method: 'GET',
path: 'getPlaylists.view',
query: ssType._parameters.getPlaylists,
responses: {
200: ssType._response.getPlaylists,
},
},
getRandomSongList: { getRandomSongList: {
method: 'GET', method: 'GET',
path: 'getRandomSongs.view', path: 'getRandomSongs.view',
@ -65,6 +137,30 @@ export const contract = c.router({
200: ssType._response.similarSongs, 200: ssType._response.similarSongs,
}, },
}, },
getSong: {
method: 'GET',
path: 'getSong.view',
query: ssType._parameters.getSong,
responses: {
200: ssType._response.getSong,
},
},
getSongsByGenre: {
method: 'GET',
path: 'getSongsByGenre.view',
query: ssType._parameters.getSongsByGenre,
responses: {
200: ssType._response.getSongsByGenre,
},
},
getStarred: {
method: 'GET',
path: 'getStarred.view',
query: ssType._parameters.getStarred,
responses: {
200: ssType._response.getStarred,
},
},
getStructuredLyrics: { getStructuredLyrics: {
method: 'GET', method: 'GET',
path: 'getLyricsBySongId.view', path: 'getLyricsBySongId.view',
@ -120,6 +216,14 @@ export const contract = c.router({
200: ssType._response.setRating, 200: ssType._response.setRating,
}, },
}, },
updatePlaylist: {
method: 'GET',
path: 'updatePlaylist.view',
query: ssType._parameters.updatePlaylist,
responses: {
200: ssType._response.baseResponse,
},
},
}); });
const axiosClient = axios.create({}); const axiosClient = axios.create({});

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@ import {
Album, Album,
ServerListItem, ServerListItem,
ServerType, ServerType,
Playlist,
Genre,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
const getCoverArtUrl = (args: { const getCoverArtUrl = (args: {
@ -36,13 +38,14 @@ const normalizeSong = (
item: z.infer<typeof ssType._response.song>, item: z.infer<typeof ssType._response.song>,
server: ServerListItem | null, server: ServerListItem | null,
deviceId: string, deviceId: string,
size?: number,
): QueueSong => { ): QueueSong => {
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: size || 300,
}) || null; }) || null;
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`; const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
@ -66,7 +69,7 @@ const normalizeSong = (
}, },
], ],
bitRate: item.bitRate || 0, bitRate: item.bitRate || 0,
bpm: null, bpm: item.bpm || null,
channels: null, channels: null,
comment: null, comment: null,
compilation: null, compilation: null,
@ -123,15 +126,18 @@ const normalizeSong = (
}; };
const normalizeAlbumArtist = ( const normalizeAlbumArtist = (
item: z.infer<typeof ssType._response.albumArtist>, item:
| z.infer<typeof ssType._response.albumArtist>
| z.infer<typeof ssType._response.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 {
@ -157,15 +163,16 @@ const normalizeAlbumArtist = (
}; };
const normalizeAlbum = ( const normalizeAlbum = (
item: z.infer<typeof ssType._response.album>, item: z.infer<typeof ssType._response.album> | z.infer<typeof ssType._response.albumListEntry>,
server: ServerListItem | null, server: ServerListItem | null,
imageSize?: number,
): Album => { ): Album => {
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: 300, size: imageSize || 300,
}) || null; }) || null;
return { return {
@ -177,7 +184,7 @@ const normalizeAlbum = (
backdropImageUrl: null, backdropImageUrl: null,
comment: null, comment: null,
createdAt: item.created, createdAt: item.created,
duration: item.duration, duration: item.duration * 1000,
genres: item.genre genres: item.genre
? [ ? [
{ {
@ -204,7 +211,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 ssType._response.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,
@ -212,8 +222,51 @@ const normalizeAlbum = (
}; };
}; };
const normalizePlaylist = (
item:
| z.infer<typeof ssType._response.playlist>
| z.infer<typeof ssType._response.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,
};
};
const normalizeGenre = (item: z.infer<typeof ssType._response.genre>): Genre => {
return {
albumCount: item.albumCount,
id: item.value,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: item.value,
songCount: item.songCount,
};
};
export const ssNormalize = { export const ssNormalize = {
album: normalizeAlbum, album: normalizeAlbum,
albumArtist: normalizeAlbumArtist, albumArtist: normalizeAlbumArtist,
genre: normalizeGenre,
playlist: normalizePlaylist,
song: normalizeSong, song: normalizeSong,
}; };

View file

@ -60,6 +60,10 @@ const songGain = z.object({
trackPeak: z.number().optional(), trackPeak: z.number().optional(),
}); });
const genreItem = z.object({
name: z.string(),
});
const song = z.object({ const song = z.object({
album: z.string().optional(), album: z.string().optional(),
albumId: z.string().optional(), albumId: z.string().optional(),
@ -67,15 +71,18 @@ const song = z.object({
artistId: z.string().optional(), artistId: z.string().optional(),
averageRating: z.number().optional(), averageRating: z.number().optional(),
bitRate: z.number().optional(), bitRate: z.number().optional(),
bpm: z.number().optional(),
contentType: z.string(), contentType: z.string(),
coverArt: z.string().optional(), coverArt: z.string().optional(),
created: z.string(), created: z.string(),
discNumber: z.number(), discNumber: z.number(),
duration: z.number().optional(), duration: z.number().optional(),
genre: z.string().optional(), genre: z.string().optional(),
genres: z.array(genreItem).optional(),
id: z.string(), id: z.string(),
isDir: z.boolean(), isDir: z.boolean(),
isVideo: z.boolean(), isVideo: z.boolean(),
musicBrainzId: z.string().optional(),
parent: z.string(), parent: z.string(),
path: z.string(), path: z.string(),
playCount: z.number().optional(), playCount: z.number().optional(),
@ -99,6 +106,7 @@ const album = z.object({
duration: z.number(), duration: z.number(),
genre: z.string().optional(), genre: z.string().optional(),
id: z.string(), id: z.string(),
isCompilation: z.boolean().optional(),
isDir: z.boolean(), isDir: z.boolean(),
isVideo: z.boolean(), isVideo: z.boolean(),
name: z.string(), name: z.string(),
@ -111,6 +119,10 @@ const album = z.object({
year: z.number().optional(), year: z.number().optional(),
}); });
const albumListEntry = album.omit({
song: true,
});
const albumListParameters = z.object({ const albumListParameters = z.object({
fromYear: z.number().optional(), fromYear: z.number().optional(),
genre: z.string().optional(), genre: z.string().optional(),
@ -124,11 +136,13 @@ const albumListParameters = z.object({
const albumList = z.array(album.omit({ song: true })); const albumList = z.array(album.omit({ song: true }));
const albumArtist = z.object({ const albumArtist = z.object({
album: z.array(album),
albumCount: z.string(), albumCount: z.string(),
artistImageUrl: z.string().optional(), artistImageUrl: z.string().optional(),
coverArt: z.string().optional(), coverArt: z.string().optional(),
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
starred: z.string().optional(),
}); });
const albumArtistList = z.object({ const albumArtistList = z.object({
@ -136,6 +150,14 @@ const albumArtistList = z.object({
name: z.string(), name: z.string(),
}); });
const artistListEntry = albumArtist.pick({
albumCount: true,
coverArt: true,
id: true,
name: true,
starred: true,
});
const artistInfoParameters = z.object({ const artistInfoParameters = z.object({
count: z.number().optional(), count: z.number().optional(),
id: z.string(), id: z.string(),
@ -274,12 +296,215 @@ export enum SubsonicExtensions {
TRANSCODE_OFFSET = 'transcodeOffset', TRANSCODE_OFFSET = 'transcodeOffset',
} }
const updatePlaylistParameters = z.object({
comment: z.string().optional(),
name: z.string().optional(),
playlistId: z.string(),
public: z.boolean().optional(),
songIdToAdd: z.array(z.string()).optional(),
songIndexToRemove: z.array(z.string()).optional(),
});
const getStarredParameters = z.object({
musicFolderId: z.string().optional(),
});
const getStarred = z.object({
starred: z.object({
album: z.array(albumListEntry),
artist: z.array(artistListEntry),
song: z.array(song),
}),
});
const getSongsByGenreParameters = z.object({
count: z.number().optional(),
genre: z.string(),
musicFolderId: z.string().optional(),
offset: z.number().optional(),
});
const getSongsByGenre = z.object({
songsByGenre: z.object({
song: z.array(song),
}),
});
const getAlbumParameters = z.object({
id: z.string(),
musicFolderId: z.string().optional(),
});
const getAlbum = z.object({
album,
});
const getArtistParameters = z.object({
id: z.string(),
});
const getArtist = z.object({
artist: albumArtist,
});
const getSongParameters = z.object({
id: z.string(),
});
const getSong = z.object({
song,
});
const getArtistsParameters = z.object({
musicFolderId: z.string().optional(),
});
const getArtists = z.object({
artists: z.object({
ignoredArticles: z.string(),
index: z.array(
z.object({
artist: z.array(artistListEntry),
name: z.string(),
}),
),
}),
});
const deletePlaylistParameters = z.object({
id: z.string(),
});
const createPlaylistParameters = z.object({
name: z.string(),
playlistId: z.string().optional(),
songId: z.array(z.string()).optional(),
});
const playlist = z.object({
changed: z.string().optional(),
comment: z.string().optional(),
coverArt: z.string().optional(),
created: z.string(),
duration: z.number(),
entry: z.array(song).optional(),
id: z.string(),
name: z.string(),
owner: z.string(),
public: z.boolean(),
songCount: z.number(),
});
const createPlaylist = z.object({
playlist,
});
const getPlaylistsParameters = z.object({
username: z.string().optional(),
});
const playlistListEntry = playlist.omit({
entry: true,
});
const getPlaylists = z.object({
playlists: z.object({
playlist: z.array(playlistListEntry),
}),
});
const getPlaylistParameters = z.object({
id: z.string(),
});
const getPlaylist = z.object({
playlist,
});
const genre = z.object({
albumCount: z.number(),
songCount: z.number(),
value: z.string(),
});
const getGenresParameters = z.object({});
const getGenres = z.object({
genres: z.object({
genre: z.array(genre),
}),
});
export enum AlbumListSortType {
ALPHABETICAL_BY_ARTIST = 'alphabeticalByArtist',
ALPHABETICAL_BY_NAME = 'alphabeticalByName',
BY_GENRE = 'byGenre',
BY_YEAR = 'byYear',
FREQUENT = 'frequent',
NEWEST = 'newest',
RANDOM = 'random',
RECENT = 'recent',
STARRED = 'starred',
}
const getAlbumList2Parameters = z
.object({
fromYear: z.number().optional(),
genre: z.string().optional(),
musicFolderId: z.string().optional(),
offset: z.number().optional(),
size: z.number().optional(),
toYear: z.number().optional(),
type: z.nativeEnum(AlbumListSortType),
})
.refine(
(val) => {
if (val.type === AlbumListSortType.BY_YEAR) {
return val.fromYear !== undefined && val.toYear !== undefined;
}
return true;
},
{
message: 'Parameters "fromYear" and "toYear" are required when using sort "byYear"',
},
)
.refine(
(val) => {
if (val.type === AlbumListSortType.BY_GENRE) {
return val.genre !== undefined;
}
return true;
},
{ message: 'Parameter "genre" is required when using sort "byGenre"' },
);
const getAlbumList2 = z.object({
albumList2: z.object({
album: z.array(albumListEntry),
}),
});
export const ssType = { export const ssType = {
_parameters: { _parameters: {
albumList: albumListParameters, albumList: albumListParameters,
artistInfo: artistInfoParameters, artistInfo: artistInfoParameters,
authenticate: authenticateParameters, authenticate: authenticateParameters,
createFavorite: createFavoriteParameters, createFavorite: createFavoriteParameters,
createPlaylist: createPlaylistParameters,
deletePlaylist: deletePlaylistParameters,
getAlbum: getAlbumParameters,
getAlbumList2: getAlbumList2Parameters,
getArtist: getArtistParameters,
getArtists: getArtistsParameters,
getGenre: getGenresParameters,
getGenres: getGenresParameters,
getPlaylist: getPlaylistParameters,
getPlaylists: getPlaylistsParameters,
getSong: getSongParameters,
getSongsByGenre: getSongsByGenreParameters,
getStarred: getStarredParameters,
randomSongList: randomSongListParameters, randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters, removeFavorite: removeFavoriteParameters,
scrobble: scrobbleParameters, scrobble: scrobbleParameters,
@ -288,18 +513,35 @@ export const ssType = {
similarSongs: similarSongsParameters, similarSongs: similarSongsParameters,
structuredLyrics: structuredLyricsParameters, structuredLyrics: structuredLyricsParameters,
topSongsList: topSongsListParameters, topSongsList: topSongsListParameters,
updatePlaylist: updatePlaylistParameters,
}, },
_response: { _response: {
album, album,
albumArtist, albumArtist,
albumArtistList, albumArtistList,
albumList, albumList,
albumListEntry,
artistInfo, artistInfo,
artistListEntry,
authenticate, authenticate,
baseResponse, baseResponse,
createFavorite, createFavorite,
createPlaylist,
genre,
getAlbum,
getAlbumList2,
getArtist,
getArtists,
getGenres,
getPlaylist,
getPlaylists,
getSong,
getSongsByGenre,
getStarred,
musicFolderList, musicFolderList,
ping, ping,
playlist,
playlistListEntry,
randomSongList, randomSongList,
removeFavorite, removeFavorite,
scrobble, scrobble,

View file

@ -1,3 +1,6 @@
import orderBy from 'lodash/orderBy';
import reverse from 'lodash/reverse';
import shuffle from 'lodash/shuffle';
import { z } from 'zod'; import { z } from 'zod';
import { ServerFeatures } from './features-types'; import { ServerFeatures } from './features-types';
import { jfType } from './jellyfin/jellyfin-types'; import { jfType } from './jellyfin/jellyfin-types';
@ -128,7 +131,7 @@ export interface BasePaginatedResponse<T> {
error?: string | any; error?: string | any;
items: T; items: T;
startIndex: number; startIndex: number;
totalRecordCount: number; totalRecordCount: number | null;
} }
export type AuthenticationResponse = { export type AuthenticationResponse = {
@ -309,6 +312,11 @@ type BaseEndpointArgs = {
}; };
}; };
export interface BaseQuery<T> {
sortBy: T;
sortOrder: SortOrder;
}
// Genre List // Genre List
export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefined; export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefined;
@ -318,7 +326,7 @@ export enum GenreListSort {
NAME = 'name', NAME = 'name',
} }
export type GenreListQuery = { export interface GenreListQuery extends BaseQuery<GenreListSort> {
_custom?: { _custom?: {
jellyfin?: null; jellyfin?: null;
navidrome?: null; navidrome?: null;
@ -326,10 +334,8 @@ export type GenreListQuery = {
limit?: number; limit?: number;
musicFolderId?: string; musicFolderId?: string;
searchTerm?: string; searchTerm?: string;
sortBy: GenreListSort;
sortOrder: SortOrder;
startIndex: number; startIndex: number;
}; }
type GenreListSortMap = { type GenreListSortMap = {
jellyfin: Record<GenreListSort, JFGenreListSort | undefined>; jellyfin: Record<GenreListSort, JFGenreListSort | undefined>;
@ -370,22 +376,22 @@ export enum AlbumListSort {
YEAR = 'year', YEAR = 'year',
} }
export type AlbumListQuery = { export interface AlbumListQuery extends BaseQuery<AlbumListSort> {
_custom?: { _custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumList>> & { jellyfin?: Partial<z.infer<typeof jfType._parameters.albumList>>;
maxYear?: number;
minYear?: number;
};
navidrome?: Partial<z.infer<typeof ndType._parameters.albumList>>; navidrome?: Partial<z.infer<typeof ndType._parameters.albumList>>;
}; };
artistIds?: string[]; artistIds?: string[];
compilation?: boolean;
favorite?: boolean;
genres?: string[];
limit?: number; limit?: number;
maxYear?: number;
minYear?: number;
musicFolderId?: string; musicFolderId?: string;
searchTerm?: string; searchTerm?: string;
sortBy: AlbumListSort;
sortOrder: SortOrder;
startIndex: number; startIndex: number;
}; }
export type AlbumListArgs = { query: AlbumListQuery } & BaseEndpointArgs; export type AlbumListArgs = { query: AlbumListQuery } & BaseEndpointArgs;
@ -481,24 +487,23 @@ export enum SongListSort {
YEAR = 'year', YEAR = 'year',
} }
export type SongListQuery = { export interface SongListQuery extends BaseQuery<SongListSort> {
_custom?: { _custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.songList>> & { jellyfin?: Partial<z.infer<typeof jfType._parameters.songList>>;
maxYear?: number;
minYear?: number;
};
navidrome?: Partial<z.infer<typeof ndType._parameters.songList>>; navidrome?: Partial<z.infer<typeof ndType._parameters.songList>>;
}; };
albumIds?: string[]; albumIds?: string[];
artistIds?: string[]; artistIds?: string[];
favorite?: boolean;
genreIds?: string[];
imageSize?: number; imageSize?: number;
limit?: number; limit?: number;
maxYear?: number;
minYear?: number;
musicFolderId?: string; musicFolderId?: string;
searchTerm?: string; searchTerm?: string;
sortBy: SongListSort;
sortOrder: SortOrder;
startIndex: number; startIndex: number;
}; }
export type SongListArgs = { query: SongListQuery } & BaseEndpointArgs; export type SongListArgs = { query: SongListQuery } & BaseEndpointArgs;
@ -595,7 +600,7 @@ export enum AlbumArtistListSort {
SONG_COUNT = 'songCount', SONG_COUNT = 'songCount',
} }
export type AlbumArtistListQuery = { export interface AlbumArtistListQuery extends BaseQuery<AlbumArtistListSort> {
_custom?: { _custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>; jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>; navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
@ -603,10 +608,8 @@ export type AlbumArtistListQuery = {
limit?: number; limit?: number;
musicFolderId?: string; musicFolderId?: string;
searchTerm?: string; searchTerm?: string;
sortBy: AlbumArtistListSort;
sortOrder: SortOrder;
startIndex: number; startIndex: number;
}; }
export type AlbumArtistListArgs = { query: AlbumArtistListQuery } & BaseEndpointArgs; export type AlbumArtistListArgs = { query: AlbumArtistListQuery } & BaseEndpointArgs;
@ -683,17 +686,15 @@ export enum ArtistListSort {
SONG_COUNT = 'songCount', SONG_COUNT = 'songCount',
} }
export type ArtistListQuery = { export interface ArtistListQuery extends BaseQuery<ArtistListSort> {
_custom?: { _custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>; jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>; navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
}; };
limit?: number; limit?: number;
musicFolderId?: string; musicFolderId?: string;
sortBy: ArtistListSort;
sortOrder: SortOrder;
startIndex: number; startIndex: number;
}; }
export type ArtistListArgs = { query: ArtistListQuery } & BaseEndpointArgs; export type ArtistListArgs = { query: ArtistListQuery } & BaseEndpointArgs;
@ -879,17 +880,15 @@ export enum PlaylistListSort {
UPDATED_AT = 'updatedAt', UPDATED_AT = 'updatedAt',
} }
export type PlaylistListQuery = { export interface PlaylistListQuery extends BaseQuery<PlaylistListSort> {
_custom?: { _custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.playlistList>>; jellyfin?: Partial<z.infer<typeof jfType._parameters.playlistList>>;
navidrome?: Partial<z.infer<typeof ndType._parameters.playlistList>>; navidrome?: Partial<z.infer<typeof ndType._parameters.playlistList>>;
}; };
limit?: number; limit?: number;
searchTerm?: string; searchTerm?: string;
sortBy: PlaylistListSort;
sortOrder: SortOrder;
startIndex: number; startIndex: number;
}; }
export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs; export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs;
@ -963,7 +962,7 @@ export enum UserListSort {
NAME = 'name', NAME = 'name',
} }
export type UserListQuery = { export interface UserListQuery extends BaseQuery<UserListSort> {
_custom?: { _custom?: {
navidrome?: { navidrome?: {
owner_id?: string; owner_id?: string;
@ -971,10 +970,8 @@ export type UserListQuery = {
}; };
limit?: number; limit?: number;
searchTerm?: string; searchTerm?: string;
sortBy: UserListSort;
sortOrder: SortOrder;
startIndex: number; startIndex: number;
}; }
export type UserListArgs = { query: UserListQuery } & BaseEndpointArgs; export type UserListArgs = { query: UserListQuery } & BaseEndpointArgs;
@ -1228,3 +1225,223 @@ export type TranscodingQuery = {
export type TranscodingArgs = { export type TranscodingArgs = {
query: TranscodingQuery; query: TranscodingQuery;
} & BaseEndpointArgs; } & BaseEndpointArgs;
export type ControllerEndpoint = {
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
authenticate: (
url: string,
body: { legacy?: boolean; password: string; username: string },
) => Promise<AuthenticationResponse>;
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
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>;
// getArtistInfo?: (args: any) => void;
// getArtistList?: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getDownloadUrl: (args: DownloadArgs) => string;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
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>;
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getSongListCount: (args: SongListArgs) => Promise<number>;
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getTranscodingUrl: (args: TranscodingArgs) => string;
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;
shareItem?: (args: ShareItemArgs) => Promise<ShareItemResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
};
export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: SortOrder) => {
let results = albums;
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
switch (sortBy) {
case AlbumListSort.ALBUM_ARTIST:
results = orderBy(
results,
['albumArtist', (v) => v.name.toLowerCase()],
[order, 'asc'],
);
break;
case AlbumListSort.DURATION:
results = orderBy(results, ['duration'], [order]);
break;
case AlbumListSort.FAVORITED:
results = orderBy(results, ['starred'], [order]);
break;
case AlbumListSort.NAME:
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
break;
case AlbumListSort.PLAY_COUNT:
results = orderBy(results, ['playCount'], [order]);
break;
case AlbumListSort.RANDOM:
results = shuffle(results);
break;
case AlbumListSort.RECENTLY_ADDED:
results = orderBy(results, ['createdAt'], [order]);
break;
case AlbumListSort.RECENTLY_PLAYED:
results = orderBy(results, ['lastPlayedAt'], [order]);
break;
case AlbumListSort.RATING:
results = orderBy(results, ['userRating'], [order]);
break;
case AlbumListSort.YEAR:
results = orderBy(results, ['releaseYear'], [order]);
break;
case AlbumListSort.SONG_COUNT:
results = orderBy(results, ['songCount'], [order]);
break;
default:
break;
}
return results;
};
export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder: SortOrder) => {
let results = songs;
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
switch (sortBy) {
case SongListSort.ALBUM:
results = orderBy(
results,
[(v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, 'asc', 'asc'],
);
break;
case SongListSort.ALBUM_ARTIST:
results = orderBy(
results,
['albumArtist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
);
break;
case SongListSort.ARTIST:
results = orderBy(
results,
['artist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
);
break;
case SongListSort.DURATION:
results = orderBy(results, ['duration'], [order]);
break;
case SongListSort.FAVORITED:
results = orderBy(results, ['userFavorite', (v) => v.name.toLowerCase()], [order]);
break;
case SongListSort.GENRE:
results = orderBy(
results,
[
(v) => v.genres?.[0].name.toLowerCase(),
(v) => v.album?.toLowerCase(),
'discNumber',
'trackNumber',
],
[order, order, 'asc', 'asc'],
);
break;
case SongListSort.ID:
if (order === 'desc') {
results = reverse(results);
}
break;
case SongListSort.NAME:
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
break;
case SongListSort.PLAY_COUNT:
results = orderBy(results, ['playCount'], [order]);
break;
case SongListSort.RANDOM:
results = shuffle(results);
break;
case SongListSort.RATING:
results = orderBy(results, ['userRating', (v) => v.name.toLowerCase()], [order]);
break;
case SongListSort.RECENTLY_ADDED:
results = orderBy(results, ['created'], [order]);
break;
case SongListSort.YEAR:
results = orderBy(
results,
['year', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
[order, 'asc', 'asc', 'asc'],
);
break;
default:
break;
}
return results;
};
export const sortAlbumArtistList = (
artists: AlbumArtist[],
sortBy: AlbumArtistListSort,
sortOrder: SortOrder,
) => {
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
let results = artists;
switch (sortBy) {
case AlbumArtistListSort.ALBUM_COUNT:
results = orderBy(artists, ['albumCount', (v) => v.name.toLowerCase()], [order, 'asc']);
break;
case AlbumArtistListSort.NAME:
results = orderBy(artists, [(v) => v.name.toLowerCase()], [order]);
break;
case AlbumArtistListSort.FAVORITED:
results = orderBy(artists, ['starred'], [order]);
break;
case AlbumArtistListSort.RATING:
results = orderBy(artists, ['userRating'], [order]);
break;
default:
break;
}
return results;
};

View file

@ -294,7 +294,7 @@ export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow<Playlist> } = {
name: { name: {
property: 'name', property: 'name',
route: { route: {
route: AppRoute.PLAYLISTS_DETAIL, route: AppRoute.PLAYLISTS_DETAIL_SONGS,
slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }], slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }],
}, },
}, },

View file

@ -7,7 +7,6 @@ import {
IDatasource, IDatasource,
PaginationChangedEvent, PaginationChangedEvent,
RowDoubleClickedEvent, RowDoubleClickedEvent,
RowModelType,
} from '@ag-grid-community/core'; } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { QueryKey, useQueryClient } from '@tanstack/react-query'; import { QueryKey, useQueryClient } from '@tanstack/react-query';
@ -16,7 +15,12 @@ import orderBy from 'lodash/orderBy';
import { generatePath, useNavigate } from 'react-router'; import { generatePath, useNavigate } from 'react-router';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { QueryPagination, queryKeys } from '/@/renderer/api/query-keys'; import { QueryPagination, queryKeys } from '/@/renderer/api/query-keys';
import { BasePaginatedResponse, LibraryItem, ServerListItem } from '/@/renderer/api/types'; import {
BasePaginatedResponse,
BaseQuery,
LibraryItem,
ServerListItem,
} from '/@/renderer/api/types';
import { getColumnDefs, VirtualTableProps } from '/@/renderer/components/virtual-table'; import { getColumnDefs, VirtualTableProps } from '/@/renderer/components/virtual-table';
import { SetContextMenuItems, useHandleTableContextMenu } from '/@/renderer/features/context-menu'; import { SetContextMenuItems, useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
@ -34,6 +38,7 @@ interface UseAgGridProps<TFilter> {
columnType?: 'albumDetail' | 'generic'; columnType?: 'albumDetail' | 'generic';
contextMenu: SetContextMenuItems; contextMenu: SetContextMenuItems;
customFilters?: Partial<TFilter>; customFilters?: Partial<TFilter>;
isClientSide?: boolean;
isClientSideSort?: boolean; isClientSideSort?: boolean;
isSearchParams?: boolean; isSearchParams?: boolean;
itemCount?: number; itemCount?: number;
@ -43,7 +48,9 @@ interface UseAgGridProps<TFilter> {
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const useVirtualTable = <TFilter>({ const BLOCK_SIZE = 500;
export const useVirtualTable = <TFilter extends BaseQuery<any>>({
server, server,
tableRef, tableRef,
pageKey, pageKey,
@ -52,13 +59,14 @@ export const useVirtualTable = <TFilter>({
itemCount, itemCount,
customFilters, customFilters,
isSearchParams, isSearchParams,
isClientSide,
isClientSideSort, isClientSideSort,
columnType, columnType,
}: UseAgGridProps<TFilter>) => { }: UseAgGridProps<TFilter>) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { setTable, setTablePagination } = useListStoreActions(); const { setTable, setTablePagination } = useListStoreActions();
const properties = useListStoreByKey({ filter: customFilters, key: pageKey }); const properties = useListStoreByKey<TFilter>({ filter: customFilters, key: pageKey });
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const scrollOffset = searchParams.get('scrollOffset'); const scrollOffset = searchParams.get('scrollOffset');
@ -182,6 +190,19 @@ export const useVirtualTable = <TFilter>({
return; return;
} }
if (results.totalRecordCount === null) {
const hasMoreRows = results?.items?.length === BLOCK_SIZE;
const lastRowIndex = hasMoreRows
? undefined
: params.startRow + results.items.length;
params.successCallback(
results?.items || [],
hasMoreRows ? undefined : lastRowIndex,
);
return;
}
params.successCallback(results?.items || [], results?.totalRecordCount || 0); params.successCallback(results?.items || [], results?.totalRecordCount || 0);
}, },
rowCount: undefined, rowCount: undefined,
@ -321,6 +342,7 @@ export const useVirtualTable = <TFilter>({
alwaysShowHorizontalScroll: true, alwaysShowHorizontalScroll: true,
autoFitColumns: properties.table.autoFit, autoFitColumns: properties.table.autoFit,
blockLoadDebounceMillis: 200, blockLoadDebounceMillis: 200,
cacheBlockSize: BLOCK_SIZE,
getRowId: (data: GetRowIdParams<any>) => data.data.id, getRowId: (data: GetRowIdParams<any>) => data.data.id,
infiniteInitialRowCount: itemCount || 100, infiniteInitialRowCount: itemCount || 100,
pagination: isPaginationEnabled, pagination: isPaginationEnabled,
@ -335,10 +357,11 @@ export const useVirtualTable = <TFilter>({
: undefined, : undefined,
rowBuffer: 20, rowBuffer: 20,
rowHeight: properties.table.rowHeight || 40, rowHeight: properties.table.rowHeight || 40,
rowModelType: 'infinite' as RowModelType, rowModelType: isClientSide ? 'clientSide' : 'infinite',
suppressRowDrag: true, suppressRowDrag: true,
}; };
}, [ }, [
isClientSide,
isPaginationEnabled, isPaginationEnabled,
isSearchParams, isSearchParams,
itemCount, itemCount,
@ -370,7 +393,9 @@ export const useVirtualTable = <TFilter>({
); );
break; break;
case LibraryItem.PLAYLIST: case LibraryItem.PLAYLIST:
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id })); navigate(
generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }),
);
break; break;
default: default:
break; break;

View file

@ -11,7 +11,13 @@ import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types'; import {
AlbumListQuery,
AlbumListSort,
LibraryItem,
QueueSong,
SortOrder,
} from '/@/renderer/api/types';
import { Button, Popover, Spoiler } from '/@/renderer/components'; import { Button, Popover, Spoiler } from '/@/renderer/components';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel'; import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import { import {
@ -164,13 +170,12 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
query: { query: {
_custom: { _custom: {
jellyfin: { jellyfin: {
AlbumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
ExcludeItemIds: detailQuery?.data?.id, ExcludeItemIds: detailQuery?.data?.id,
}, },
navidrome: {
artist_id: detailQuery?.data?.albumArtists[0]?.id,
},
}, },
artistIds: detailQuery?.data?.albumArtists.length
? [detailQuery?.data?.albumArtists[0].id]
: undefined,
limit: 15, limit: 15,
sortBy: AlbumListSort.YEAR, sortBy: AlbumListSort.YEAR,
sortOrder: SortOrder.DESC, sortOrder: SortOrder.DESC,
@ -179,15 +184,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
serverId: server?.id, serverId: server?.id,
}); });
const relatedAlbumGenresRequest = { const relatedAlbumGenresRequest: AlbumListQuery = {
_custom: { genres: detailQuery.data?.genres.length ? [detailQuery.data.genres[0].id] : undefined,
jellyfin: {
GenreIds: detailQuery?.data?.genres?.[0]?.id,
},
navidrome: {
genre_id: detailQuery?.data?.genres?.[0]?.id,
},
},
limit: 15, limit: 15,
sortBy: AlbumListSort.RANDOM, sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC, sortOrder: SortOrder.ASC,

View file

@ -29,7 +29,7 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
const server = useCurrentServer(); const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const { pageKey, customFilters, id } = useListContext(); const { pageKey, customFilters, id } = useListContext();
const { grid, display, filter } = useListStoreByKey({ key: pageKey }); const { grid, display, filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
const { setGrid } = useListStoreActions(); const { setGrid } = useListStoreActions();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -162,9 +162,9 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
const query: AlbumListQuery = { const query: AlbumListQuery = {
limit: take, limit: take,
startIndex: skip,
...filter, ...filter,
...customFilters, ...customFilters,
startIndex: skip,
}; };
const queryKey = queryKeys.albums.list(server?.id || '', query, id); const queryKey = queryKeys.albums.list(server?.id || '', query, id);

View file

@ -15,13 +15,20 @@ import {
RiSettings3Fill, RiSettings3Fill,
} from 'react-icons/ri'; } from 'react-icons/ri';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types'; import {
AlbumListQuery,
AlbumListSort,
LibraryItem,
ServerType,
SortOrder,
} from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters'; import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters'; import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters';
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared'; import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks'; import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
@ -139,26 +146,74 @@ 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,
itemCount,
tableRef,
}: AlbumListHeaderFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { pageKey, customFilters, handlePlay } = useListContext(); const { pageKey, customFilters, handlePlay } = useListContext();
const server = useCurrentServer(); const server = useCurrentServer();
const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions(); const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions();
const { display, filter, table, grid } = useListStoreByKey({ const { display, filter, table, grid } = useListStoreByKey<AlbumListQuery>({
filter: customFilters, filter: customFilters,
key: pageKey, key: pageKey,
}); });
const cq = useContainerQuery(); const cq = useContainerQuery();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
server, server,
}); });
@ -191,27 +246,35 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
); );
const handleOpenFiltersModal = () => { const handleOpenFiltersModal = () => {
let FilterComponent;
switch (server?.type) {
case ServerType.NAVIDROME:
FilterComponent = NavidromeAlbumFilters;
break;
case ServerType.JELLYFIN:
FilterComponent = JellyfinAlbumFilters;
break;
case ServerType.SUBSONIC:
FilterComponent = SubsonicAlbumFilters;
break;
default:
break;
}
if (!FilterComponent) {
return;
}
openModal({ openModal({
children: ( children: (
<> <FilterComponent
{server?.type === ServerType.NAVIDROME ? ( customFilters={customFilters}
<NavidromeAlbumFilters disableArtistFilter={!!customFilters}
customFilters={customFilters} pageKey={pageKey}
disableArtistFilter={!!customFilters} serverId={server?.id}
pageKey={pageKey} onFilterChange={onFilterChange}
serverId={server?.id} />
onFilterChange={onFilterChange}
/>
) : (
<JellyfinAlbumFilters
customFilters={customFilters}
disableArtistFilter={!!customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
)}
</>
), ),
title: 'Album Filters', title: 'Album Filters',
}); });
@ -347,8 +410,20 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
filter?._custom?.jellyfin && filter?._custom?.jellyfin &&
Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined); Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined);
return isNavidromeFilterApplied || isJellyfinFilterApplied; const isSubsonicFilterApplied =
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]); server?.type === ServerType.SUBSONIC &&
(filter.maxYear || filter.minYear || filter.genres?.length || filter.favorite);
return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied;
}, [
filter?._custom?.jellyfin,
filter?._custom?.navidrome,
filter.favorite,
filter.genres?.length,
filter.maxYear,
filter.minYear,
server?.type,
]);
const isFolderFilterApplied = useMemo(() => { const isFolderFilterApplied = useMemo(() => {
return filter.musicFolderId !== undefined; return filter.musicFolderId !== undefined;

View file

@ -3,7 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { Flex, Group, Stack } from '@mantine/core'; import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LibraryItem } from '/@/renderer/api/types'; import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components'; import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters'; import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
@ -33,8 +33,9 @@ export const AlbumListHeader = ({
const cq = useContainerQuery(); const cq = useContainerQuery();
const playButtonBehavior = usePlayButtonBehavior(); const playButtonBehavior = usePlayButtonBehavior();
const genreRef = useRef<string>(); const genreRef = useRef<string>();
const { filter, handlePlay, refresh, search } = useDisplayRefresh({ const { filter, handlePlay, refresh, search } = useDisplayRefresh<AlbumListQuery>({
gridRef, gridRef,
itemCount,
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
server, server,
tableRef, tableRef,
@ -90,6 +91,7 @@ export const AlbumListHeader = ({
<FilterBar> <FilterBar>
<AlbumListHeaderFilters <AlbumListHeaderFilters
gridRef={gridRef} gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef} tableRef={tableRef}
/> />
</FilterBar> </FilterBar>

View file

@ -3,7 +3,13 @@ import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useListFilterByKey } from '../../../store/list.store'; import { useListFilterByKey } from '../../../store/list.store';
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; import {
AlbumArtistListSort,
AlbumListQuery,
GenreListSort,
LibraryItem,
SortOrder,
} from '/@/renderer/api/types';
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components'; import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { useGenreList } from '/@/renderer/features/genres'; import { useGenreList } from '/@/renderer/features/genres';
@ -25,7 +31,7 @@ export const JellyfinAlbumFilters = ({
serverId, serverId,
}: JellyfinAlbumFiltersProps) => { }: JellyfinAlbumFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const filter = useListFilterByKey({ key: pageKey }); const filter = useListFilterByKey<AlbumListQuery>({ key: pageKey });
const { setFilter } = useListStoreActions(); const { setFilter } = useListStoreActions();
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library // TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
@ -47,10 +53,6 @@ export const JellyfinAlbumFilters = ({
})); }));
}, [genreListQuery.data]); }, [genreListQuery.data]);
const selectedGenres = useMemo(() => {
return filter?._custom?.jellyfin?.GenreIds?.split(',');
}, [filter?._custom?.jellyfin?.GenreIds]);
const toggleFilters = [ const toggleFilters = [
{ {
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
@ -58,20 +60,15 @@ export const JellyfinAlbumFilters = ({
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: { _custom: filter?._custom,
...filter?._custom, favorite: e.currentTarget.checked ? true : undefined,
jellyfin: {
...filter?._custom?.jellyfin,
IsFavorite: e.currentTarget.checked ? true : undefined,
},
},
}, },
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
key: pageKey, key: pageKey,
}) as AlbumListFilter; }) as AlbumListFilter;
onFilterChange(updatedFilters); onFilterChange(updatedFilters);
}, },
value: filter?._custom?.jellyfin?.IsFavorite, value: filter?.favorite,
}, },
]; ];
@ -80,13 +77,8 @@ export const JellyfinAlbumFilters = ({
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: { _custom: filter?._custom,
...filter?._custom, minYear: e === '' ? undefined : (e as number),
jellyfin: {
...filter?._custom?.jellyfin,
minYear: e === '' ? undefined : (e as number),
},
},
}, },
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
key: pageKey, key: pageKey,
@ -99,13 +91,8 @@ export const JellyfinAlbumFilters = ({
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: { _custom: filter?._custom,
...filter?._custom, maxYear: e === '' ? undefined : (e as number),
jellyfin: {
...filter?._custom?.jellyfin,
maxYear: e === '' ? undefined : (e as number),
},
},
}, },
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
key: pageKey, key: pageKey,
@ -114,17 +101,11 @@ export const JellyfinAlbumFilters = ({
}, 500); }, 500);
const handleGenresFilter = debounce((e: string[] | undefined) => { const handleGenresFilter = debounce((e: string[] | undefined) => {
const genreFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: { _custom: filter?._custom,
...filter?._custom, genres: e,
jellyfin: {
...filter?._custom?.jellyfin,
GenreIds: genreFilterString,
},
},
}, },
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
key: pageKey, key: pageKey,
@ -157,17 +138,11 @@ export const JellyfinAlbumFilters = ({
}, [albumArtistListQuery?.data?.items]); }, [albumArtistListQuery?.data?.items]);
const handleAlbumArtistFilter = (e: string[] | null) => { const handleAlbumArtistFilter = (e: string[] | null) => {
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: { _custom: filter?._custom,
...filter?._custom, artistIds: e || undefined,
jellyfin: {
...filter?._custom?.jellyfin,
AlbumArtistIds: albumArtistFilterString,
},
},
}, },
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
key: pageKey, key: pageKey,
@ -193,21 +168,21 @@ export const JellyfinAlbumFilters = ({
<Divider my="0.5rem" /> <Divider my="0.5rem" />
<Group grow> <Group grow>
<NumberInput <NumberInput
defaultValue={filter?._custom?.jellyfin?.minYear} defaultValue={filter?.minYear}
hideControls={false} hideControls={false}
label={t('filter.fromYear', { postProcess: 'sentenceCase' })} label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={2300} max={2300}
min={1700} min={1700}
required={!!filter?._custom?.jellyfin?.maxYear} required={!!filter?.maxYear}
onChange={(e) => handleMinYearFilter(e)} onChange={(e) => handleMinYearFilter(e)}
/> />
<NumberInput <NumberInput
defaultValue={filter?._custom?.jellyfin?.maxYear} defaultValue={filter?.maxYear}
hideControls={false} hideControls={false}
label={t('filter.toYear', { postProcess: 'sentenceCase' })} label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={2300} max={2300}
min={1700} min={1700}
required={!!filter?._custom?.jellyfin?.minYear} required={!!filter?.minYear}
onChange={(e) => handleMaxYearFilter(e)} onChange={(e) => handleMaxYearFilter(e)}
/> />
</Group> </Group>
@ -216,7 +191,7 @@ export const JellyfinAlbumFilters = ({
clearable clearable
searchable searchable
data={genreList} data={genreList}
defaultValue={selectedGenres} defaultValue={filter.genres}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })} label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={handleGenresFilter} onChange={handleGenresFilter}
/> />

View file

@ -5,7 +5,13 @@ import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/rend
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres'; import { useGenreList } from '/@/renderer/features/genres';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; import {
AlbumArtistListSort,
AlbumListQuery,
GenreListSort,
LibraryItem,
SortOrder,
} from '/@/renderer/api/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface NavidromeAlbumFiltersProps { interface NavidromeAlbumFiltersProps {
@ -24,7 +30,7 @@ export const NavidromeAlbumFilters = ({
serverId, serverId,
}: NavidromeAlbumFiltersProps) => { }: NavidromeAlbumFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { filter } = useListStoreByKey({ key: pageKey }); const { filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
const { setFilter } = useListStoreActions(); const { setFilter } = useListStoreActions();
const genreListQuery = useGenreList({ const genreListQuery = useGenreList({
@ -48,13 +54,8 @@ export const NavidromeAlbumFilters = ({
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: { _custom: filter._custom,
...filter._custom, genres: e ? [e] : undefined,
navidrome: {
...filter._custom?.navidrome,
genre_id: e || undefined,
},
},
}, },
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
key: pageKey, key: pageKey,
@ -90,20 +91,15 @@ export const NavidromeAlbumFilters = ({
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: { _custom: filter._custom,
...filter._custom, favorite: e.currentTarget.checked ? true : undefined,
navidrome: {
...filter._custom?.navidrome,
starred: e.currentTarget.checked ? true : undefined,
},
},
}, },
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
key: pageKey, key: pageKey,
}) as AlbumListFilter; }) as AlbumListFilter;
onFilterChange(updatedFilters); onFilterChange(updatedFilters);
}, },
value: filter._custom?.navidrome?.starred, value: filter.favorite,
}, },
{ {
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }), label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
@ -111,20 +107,15 @@ export const NavidromeAlbumFilters = ({
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: { _custom: filter._custom,
...filter._custom, compilation: e.currentTarget.checked ? true : undefined,
navidrome: {
...filter._custom?.navidrome,
compilation: e.currentTarget.checked ? true : undefined,
},
},
}, },
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
key: pageKey, key: pageKey,
}) as AlbumListFilter; }) as AlbumListFilter;
onFilterChange(updatedFilters); onFilterChange(updatedFilters);
}, },
value: filter._custom?.navidrome?.compilation, value: filter.compilation,
}, },
{ {
label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }), label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }),

View file

@ -0,0 +1,141 @@
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AlbumListQuery, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
interface SubsonicAlbumFiltersProps {
onFilterChange: (filters: AlbumListFilter) => void;
pageKey: string;
serverId?: string;
}
export const SubsonicAlbumFilters = ({
onFilterChange,
pageKey,
serverId,
}: SubsonicAlbumFiltersProps) => {
const { t } = useTranslation();
const { filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
const { setFilter } = useListStoreActions();
const genreListQuery = useGenreList({
query: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
});
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.items.map((genre) => ({
label: genre.name,
value: genre.id,
}));
}, [genreListQuery.data]);
const handleGenresFilter = debounce((e: string | null) => {
const updatedFilters = setFilter({
data: {
genres: e ? [e] : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
}, 250);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
favorite: e.target.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter.favorite,
},
];
const handleYearFilter = debounce((e: number | string, type: 'min' | 'max') => {
let data: Partial<AlbumListQuery> = {};
if (type === 'min') {
data = {
minYear: e ? Number(e) : undefined,
};
} else {
data = {
maxYear: e ? Number(e) : undefined,
};
}
const updatedFilters = setFilter({
data,
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
}, 500);
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
key={`nd-filter-${filter.label}`}
position="apart"
>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter.minYear}
disabled={filter.genres?.length !== undefined}
hideControls={false}
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e, 'min')}
/>
<NumberInput
defaultValue={filter.maxYear}
disabled={filter.genres?.length !== undefined}
hideControls={false}
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e, 'max')}
/>
</Group>
<Group grow>
<Select
clearable
searchable
data={genreList}
defaultValue={filter.genres?.length ? filter.genres[0] : undefined}
disabled={Boolean(filter.minYear || filter.maxYear)}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={handleGenresFilter}
/>
</Group>
</Stack>
);
};

View file

@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { AlbumListQuery } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useAlbumListCount = (args: QueryHookArgs<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 || '',
Object.keys(query).length === 0 ? undefined : query,
),
...options,
});
};

View file

@ -5,12 +5,11 @@ import { useTranslation } from 'react-i18next';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; import { AlbumListQuery, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { 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';
@ -18,6 +17,7 @@ import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
import { Play } from '/@/renderer/types'; import { Play } from '/@/renderer/types';
import { useGenreList } from '/@/renderer/features/genres'; import { useGenreList } from '/@/renderer/features/genres';
import { titleCase } from '/@/renderer/utils'; import { titleCase } from '/@/renderer/utils';
import { useAlbumListCount } from '/@/renderer/features/albums/queries/album-list-count-query';
const AlbumListRoute = () => { const AlbumListRoute = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -33,14 +33,7 @@ const AlbumListRoute = () => {
const value = { const value = {
...(albumArtistId && { artistIds: [albumArtistId] }), ...(albumArtistId && { artistIds: [albumArtistId] }),
...(genreId && { ...(genreId && {
_custom: { genres: [genreId],
jellyfin: {
GenreIds: genreId,
},
navidrome: {
genre_id: genreId,
},
},
}), }),
}; };
@ -51,7 +44,7 @@ const AlbumListRoute = () => {
return value; return value;
}, [albumArtistId, genreId]); }, [albumArtistId, genreId]);
const albumListFilter = useListFilterByKey({ const albumListFilter = useListFilterByKey<AlbumListQuery>({
filter: customFilters, filter: customFilters,
key: pageKey, key: pageKey,
}); });
@ -78,32 +71,27 @@ const AlbumListRoute = () => {
return genre?.name; return genre?.name;
}, [genreId, genreList.data]); }, [genreId, genreList.data]);
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 }) => {
if (!itemCount || itemCount === 0) return; if (!itemCount || itemCount === 0) return;
const { playType } = args; const { playType } = args;
const query = { const query = {
startIndex: 0,
...albumListFilter, ...albumListFilter,
...customFilters, ...customFilters,
startIndex: 0,
}; };
const queryKey = queryKeys.albums.list(server?.id || '', query); const queryKey = queryKeys.albums.list(server?.id || '', query);

View file

@ -111,18 +111,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
enabled: enabledItem.recentAlbums, enabled: enabledItem.recentAlbums,
}, },
query: { query: {
_custom: { artistIds: [albumArtistId],
jellyfin: {
...(server?.type === ServerType.JELLYFIN
? { AlbumArtistIds: albumArtistId }
: undefined),
},
navidrome: {
...(server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: false }
: undefined),
},
},
limit: 15, limit: 15,
sortBy: AlbumListSort.RELEASE_DATE, sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC, sortOrder: SortOrder.DESC,
@ -133,21 +122,11 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
const compilationAlbumsQuery = useAlbumList({ const compilationAlbumsQuery = useAlbumList({
options: { options: {
enabled: enabledItem.compilations, enabled: enabledItem.compilations && server?.type !== ServerType.SUBSONIC,
}, },
query: { query: {
_custom: { artistIds: [albumArtistId],
jellyfin: { compilation: true,
...(server?.type === ServerType.JELLYFIN
? { ContributingArtistIds: albumArtistId }
: undefined),
},
navidrome: {
...(server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: true }
: undefined),
},
},
limit: 15, limit: 15,
sortBy: AlbumListSort.RELEASE_DATE, sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC, sortOrder: SortOrder.DESC,
@ -254,7 +233,10 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
}, },
{ {
data: compilationAlbumsQuery?.data?.items, data: compilationAlbumsQuery?.data?.items,
isHidden: !compilationAlbumsQuery?.data?.items?.length || !enabledItem.compilations, isHidden:
!compilationAlbumsQuery?.data?.items?.length ||
!enabledItem.compilations ||
server?.type === ServerType.SUBSONIC,
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching, loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
order: itemOrder.compilations, order: itemOrder.compilations,
@ -301,6 +283,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
recentAlbumsQuery?.data?.items, recentAlbumsQuery?.data?.items,
recentAlbumsQuery.isFetching, recentAlbumsQuery.isFetching,
recentAlbumsQuery?.isLoading, recentAlbumsQuery?.isLoading,
server?.type,
t, t,
]); ]);

View file

@ -26,16 +26,19 @@ export const AlbumArtistDetailHeader = forwardRef(
const metadataItems = [ const metadataItems = [
{ {
enabled: detailQuery?.data?.albumCount,
id: 'albumCount', id: 'albumCount',
secondary: false, secondary: false,
value: t('entity.albumWithCount', { count: detailQuery?.data?.albumCount || 0 }), value: t('entity.albumWithCount', { count: detailQuery?.data?.albumCount || 0 }),
}, },
{ {
enabled: detailQuery?.data?.songCount,
id: 'songCount', id: 'songCount',
secondary: false, secondary: false,
value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }), value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }),
}, },
{ {
enabled: detailQuery.data?.duration,
id: 'duration', id: 'duration',
secondary: true, secondary: true,
value: value:
@ -70,7 +73,7 @@ export const AlbumArtistDetailHeader = forwardRef(
<Stack> <Stack>
<Group> <Group>
{metadataItems {metadataItems
.filter((i) => i.value) .filter((i) => i.enabled)
.map((item, index) => ( .map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}> <Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>} {index > 0 && <Text $noSelect></Text>}

View file

@ -11,7 +11,6 @@ import {
AlbumArtistListQuery, AlbumArtistListQuery,
AlbumArtistListResponse, AlbumArtistListResponse,
AlbumArtistListSort, AlbumArtistListSort,
ArtistListQuery,
LibraryItem, LibraryItem,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components'; import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
@ -34,7 +33,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const { pageKey } = useListContext(); const { pageKey } = useListContext();
const { grid, display, filter } = useListStoreByKey({ key: pageKey }); const { grid, display, filter } = useListStoreByKey<AlbumArtistListQuery>({ key: pageKey });
const { setGrid } = useListStoreActions(); const { setGrid } = useListStoreActions();
const handleFavorite = useHandleFavorite({ gridRef, server }); const handleFavorite = useHandleFavorite({ gridRef, server });
@ -73,7 +72,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
const fetch = useCallback( const fetch = useCallback(
async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => { async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => {
const query: ArtistListQuery = { const query: AlbumArtistListQuery = {
...filter, ...filter,
limit, limit,
startIndex, startIndex,
@ -91,7 +90,6 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
}, },
query: { query: {
limit, limit,
startIndex,
...filter, ...filter,
}, },
}), }),

View file

@ -9,7 +9,13 @@ import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react
import { useListContext } from '../../../context/list-context'; import { useListContext } from '../../../context/list-context';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumArtistListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types'; import {
AlbumArtistListQuery,
AlbumArtistListSort,
LibraryItem,
ServerType,
SortOrder,
} from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
@ -85,6 +91,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 {
@ -100,7 +128,9 @@ export const AlbumArtistListHeaderFilters = ({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const server = useCurrentServer(); const server = useCurrentServer();
const { pageKey } = useListContext(); const { pageKey } = useListContext();
const { display, table, grid, filter } = useListStoreByKey({ key: pageKey }); const { display, table, grid, filter } = useListStoreByKey<AlbumArtistListQuery>({
key: pageKey,
});
const { setFilter, setTable, setTablePagination, setDisplayType, setGrid } = const { setFilter, setTable, setTablePagination, setDisplayType, setGrid } =
useListStoreActions(); useListStoreActions();
const cq = useContainerQuery(); const cq = useContainerQuery();

View file

@ -4,7 +4,7 @@ import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FilterBar } from '../../shared/components/filter-bar'; import { FilterBar } from '../../shared/components/filter-bar';
import { LibraryItem } from '/@/renderer/api/types'; import { AlbumArtistListQuery, LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components'; import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters'; import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
@ -28,8 +28,9 @@ export const AlbumArtistListHeader = ({
const server = useCurrentServer(); const server = useCurrentServer();
const cq = useContainerQuery(); const cq = useContainerQuery();
const { filter, refresh, search } = useDisplayRefresh({ const { filter, refresh, search } = useDisplayRefresh<AlbumArtistListQuery>({
gridRef, gridRef,
itemCount,
itemType: LibraryItem.ALBUM_ARTIST, itemType: LibraryItem.ALBUM_ARTIST,
server, server,
tableRef, tableRef,

View file

@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumArtistListQuery } from '/@/renderer/api/types';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useAlbumArtistListCount = (args: QueryHookArgs<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 || '',
Object.keys(query).length === 0 ? undefined : query,
),
...options,
});
};

View file

@ -13,7 +13,7 @@ export const useTopSongsList = (args: QueryHookArgs<TopSongListQuery>) => {
enabled: !!server?.id, enabled: !!server?.id,
queryFn: ({ signal }) => { queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found'); if (!server) throw new Error('Server not found');
return api.controller.getTopSongList({ apiClientProps: { server, signal }, query }); return api.controller.getTopSongs({ apiClientProps: { server, signal }, query });
}, },
queryKey: queryKeys.albumArtists.topSongs(server?.id || '', query), queryKey: queryKeys.albumArtists.topSongs(server?.id || '', query),
...options, ...options,

View file

@ -2,13 +2,13 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { useCurrentServer } from '../../../store/auth.store'; import { useCurrentServer } from '../../../store/auth.store';
import { useListFilterByKey } from '../../../store/list.store'; import { useListFilterByKey } from '../../../store/list.store';
import { LibraryItem } from '/@/renderer/api/types'; import { AlbumArtistListQuery, LibraryItem } from '/@/renderer/api/types';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { 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 { AnimatedPage } from '/@/renderer/features/shared'; import { AnimatedPage } from '/@/renderer/features/shared';
import { useAlbumArtistListCount } from '/@/renderer/features/artists/queries/album-artist-list-count-query';
const AlbumArtistListRoute = () => { const AlbumArtistListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null); const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
@ -16,25 +16,18 @@ const AlbumArtistListRoute = () => {
const pageKey = LibraryItem.ALBUM_ARTIST; const pageKey = LibraryItem.ALBUM_ARTIST;
const server = useCurrentServer(); const server = useCurrentServer();
const albumArtistListFilter = useListFilterByKey({ key: pageKey }); const albumArtistListFilter = useListFilterByKey<AlbumArtistListQuery>({ key: pageKey });
const itemCountCheck = useAlbumArtistList({ const itemCountCheck = useAlbumArtistListCount({
options: { options: {
cacheTime: 1000 * 60, cacheTime: 1000 * 60,
staleTime: 1000 * 60, staleTime: 1000 * 60,
}, },
query: { query: albumArtistListFilter,
limit: 1,
startIndex: 0,
...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 {

View file

@ -494,17 +494,24 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const removeFromPlaylistMutation = useRemoveFromPlaylist(); const removeFromPlaylistMutation = useRemoveFromPlaylist();
const handleRemoveFromPlaylist = useCallback(() => { const handleRemoveFromPlaylist = useCallback(() => {
const songId = let songId: string[] | undefined;
(serverType === ServerType.NAVIDROME || ServerType.JELLYFIN
? ctx.dataNodes?.map((node) => node.data.playlistItemId) switch (serverType) {
: ctx.dataNodes?.map((node) => node.data.id)) || []; case ServerType.NAVIDROME:
case ServerType.JELLYFIN:
songId = ctx.dataNodes?.map((node) => node.data.playlistItemId);
break;
case ServerType.SUBSONIC:
songId = ctx.dataNodes?.map((node) => node.rowIndex!.toString());
break;
}
const confirm = () => { const confirm = () => {
removeFromPlaylistMutation.mutate( removeFromPlaylistMutation.mutate(
{ {
query: { query: {
id: ctx.context.playlistId, id: ctx.context.playlistId,
songId, songId: songId || [],
}, },
serverId: ctx.data?.[0]?.serverId, serverId: ctx.data?.[0]?.serverId,
}, },

View file

@ -22,7 +22,7 @@ export const GenreListGridView = ({ gridRef, itemCount }: any) => {
const server = useCurrentServer(); const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const { pageKey, id } = useListContext(); const { pageKey, id } = useListContext();
const { grid, display, filter } = useListStoreByKey({ key: pageKey }); const { grid, display, filter } = useListStoreByKey<GenreListQuery>({ key: pageKey });
const { setGrid } = useListStoreActions(); const { setGrid } = useListStoreActions();
const genrePath = useGenreRoute(); const genrePath = useGenreRoute();

View file

@ -12,7 +12,13 @@ import {
RiSettings3Fill, RiSettings3Fill,
} from 'react-icons/ri'; } from 'react-icons/ri';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { GenreListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types'; import {
GenreListQuery,
GenreListSort,
LibraryItem,
ServerType,
SortOrder,
} from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
@ -47,25 +53,38 @@ const FILTERS = {
value: GenreListSort.NAME, value: GenreListSort.NAME,
}, },
], ],
subsonic: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: GenreListSort.NAME,
},
],
}; };
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,
itemCount,
tableRef,
}: GenreListHeaderFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { pageKey, customFilters } = useListContext(); const { pageKey, customFilters } = useListContext();
const server = useCurrentServer(); const server = useCurrentServer();
const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions(); const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions();
const { display, filter, table, grid } = useListStoreByKey({ key: pageKey }); const { display, filter, table, grid } = useListStoreByKey<GenreListQuery>({ key: pageKey });
const cq = useContainerQuery(); const cq = useContainerQuery();
const { genreTarget } = useGeneralSettings(); const { genreTarget } = useGeneralSettings();
const { setGenreBehavior } = useSettingsStoreActions(); const { setGenreBehavior } = useSettingsStoreActions();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.GENRE, itemType: LibraryItem.GENRE,
server, server,
}); });

View file

@ -2,7 +2,7 @@ import { ChangeEvent, MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core'; import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { LibraryItem } from '/@/renderer/api/types'; import { GenreListQuery, LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components'; import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters'; import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters';
@ -22,7 +22,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
const { t } = useTranslation(); const { t } = useTranslation();
const cq = useContainerQuery(); const cq = useContainerQuery();
const server = useCurrentServer(); const server = useCurrentServer();
const { filter, refresh, search } = useDisplayRefresh({ const { filter, refresh, search } = useDisplayRefresh<GenreListQuery>({
gridRef, gridRef,
itemType: LibraryItem.GENRE, itemType: LibraryItem.GENRE,
server, server,
@ -66,6 +66,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
<FilterBar> <FilterBar>
<GenreListHeaderFilters <GenreListHeaderFilters
gridRef={gridRef} gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef} tableRef={tableRef}
/> />
</FilterBar> </FilterBar>

View file

@ -8,19 +8,20 @@ import { useGenreList } from '/@/renderer/features/genres/queries/genre-list-que
import { AnimatedPage } from '/@/renderer/features/shared'; import { AnimatedPage } from '/@/renderer/features/shared';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { useListStoreByKey } from '../../../store/list.store'; import { useListStoreByKey } from '../../../store/list.store';
import { GenreListQuery } from '/@/renderer/api/types';
const GenreListRoute = () => { const GenreListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null); const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
const tableRef = useRef<AgGridReactType | null>(null); const tableRef = useRef<AgGridReactType | null>(null);
const server = useCurrentServer(); const server = useCurrentServer();
const pageKey = 'genre'; const pageKey = 'genre';
const { filter } = useListStoreByKey({ key: pageKey }); const { filter } = useListStoreByKey<GenreListQuery>({ key: pageKey });
const itemCountCheck = useGenreList({ const itemCountCheck = useGenreList({
query: { query: {
...filter,
limit: 1, limit: 1,
startIndex: 0, startIndex: 0,
...filter,
}, },
serverId: server?.id, serverId: server?.id,
}); });

View file

@ -9,7 +9,6 @@ import {
SongListSort, SongListSort,
SortOrder, SortOrder,
ServerListItem, ServerListItem,
ServerType,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
export const getPlaylistSongsById = async (args: { export const getPlaylistSongsById = async (args: {
@ -103,18 +102,7 @@ export const getGenreSongsById = async (args: {
}; };
for (const genreId of id) { for (const genreId of id) {
const queryFilter: SongListQuery = { const queryFilter: SongListQuery = {
_custom: { genreIds: [genreId],
...(server?.type === ServerType.JELLYFIN && {
jellyfin: {
GenreIds: genreId,
},
}),
...(server?.type === ServerType.NAVIDROME && {
navidrome: {
genre_id: genreId,
},
}),
},
sortBy: SongListSort.GENRE, sortBy: SongListSort.GENRE,
sortOrder: SortOrder.ASC, sortOrder: SortOrder.ASC,
startIndex: 0, startIndex: 0,
@ -140,7 +128,9 @@ export const getGenreSongsById = async (args: {
); );
data.items.push(...res!.items); data.items.push(...res!.items);
data.totalRecordCount += res!.totalRecordCount; if (data.totalRecordCount) {
data.totalRecordCount += res!.totalRecordCount || 0;
}
} }
return data; return data;
@ -202,14 +192,15 @@ export const getSongsByQuery = async (args: {
const res = await queryClient.fetchQuery( const res = await queryClient.fetchQuery(
queryKey, queryKey,
async ({ signal }) => async ({ signal }) => {
api.controller.getSongList({ return api.controller.getSongList({
apiClientProps: { apiClientProps: {
server, server,
signal, signal,
}, },
query: queryFilter, query: queryFilter,
}), });
},
{ {
cacheTime: 1000 * 60, cacheTime: 1000 * 60,
staleTime: 1000 * 60, staleTime: 1000 * 60,

View file

@ -151,7 +151,12 @@ export const AddToPlaylistContextModal = ({
server, server,
signal, signal,
}, },
query: { id: playlistId, startIndex: 0 }, query: {
id: playlistId,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
}); });
}); });

View file

@ -1,252 +0,0 @@
import { MutableRefObject, useMemo, useRef } from 'react';
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Box, Group } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { useTranslation } from 'react-i18next';
import { RiMoreFill } from 'react-icons/ri';
import { generatePath, useNavigate, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { useListStoreByKey } from '../../../store/list.store';
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
import { Button, ConfirmModal, DropdownMenu, MotionGroup, toast } from '/@/renderer/components';
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import {
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { usePlaylistSongListInfinite } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
import { PlayButton, PLAY_TYPES } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
const ContentContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
padding: 1rem 2rem 5rem;
overflow: hidden;
.ag-theme-alpine-dark {
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
}
`;
interface PlaylistDetailContentProps {
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { playlistId } = useParams() as { playlistId: string };
const { table } = useListStoreByKey({ key: LibraryItem.SONG });
const handlePlayQueueAdd = usePlayQueueAdd();
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const playButtonBehavior = usePlayButtonBehavior();
const playlistSongsQueryInfinite = usePlaylistSongListInfinite({
options: {
cacheTime: 0,
keepPreviousData: false,
},
query: {
id: playlistId,
limit: 50,
startIndex: 0,
},
serverId: server?.id,
});
const handleLoadMore = () => {
playlistSongsQueryInfinite.fetchNextPage();
};
const columnDefs: ColDef[] = useMemo(
() =>
getColumnDefs(table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'),
[table.columns],
);
const contextMenuItems = useMemo(() => {
if (detailQuery?.data?.rules) {
return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
}
return PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
}, [detailQuery?.data?.rules]);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, {
playlistId,
});
const playlistSongData = useMemo(
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p?.items),
[playlistSongsQueryInfinite.data?.pages],
);
const deletePlaylistMutation = useDeletePlaylist({});
const handleDeletePlaylist = () => {
deletePlaylistMutation.mutate(
{ query: { id: playlistId }, serverId: server?.id },
{
onError: (err) => {
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
closeAllModals();
navigate(AppRoute.PLAYLISTS);
},
},
);
};
const openDeletePlaylist = () => {
openModal({
children: (
<ConfirmModal
loading={deletePlaylistMutation.isLoading}
onConfirm={handleDeletePlaylist}
>
Are you sure you want to delete this playlist?
</ConfirmModal>
),
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
});
};
const handlePlay = (playType?: Play) => {
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
playType: playType || playButtonBehavior,
});
};
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
const loadMoreRef = useRef<HTMLButtonElement | null>(null);
return (
<ContentContainer>
<Group
p="1rem"
position="apart"
>
<Group>
<PlayButton onClick={() => handlePlay()} />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
variant="subtle"
>
<RiMoreFill size={20} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map(
(type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={() => handlePlay(type.play)}
>
{type.label}
</DropdownMenu.Item>
),
)}
<DropdownMenu.Divider />
<DropdownMenu.Item
onClick={() => {
if (!detailQuery.data || !server) return;
openUpdatePlaylistModal({ playlist: detailQuery.data, server });
}}
>
Edit playlist
</DropdownMenu.Item>
<DropdownMenu.Item onClick={openDeletePlaylist}>
Delete playlist
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
uppercase
component={Link}
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
variant="subtle"
>
View full playlist
</Button>
</Group>
</Group>
<Box>
<VirtualTable
ref={tableRef}
autoFitColumns
autoHeight
deselectOnClickOutside
shouldUpdateSong
stickyHeader
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={columnDefs}
getRowId={(data) => `${data.data.uniqueId}-${data.data.pageIndex}`}
rowClassRules={rowClassRules}
rowData={playlistSongData}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
<MotionGroup
p="2rem"
position="center"
onViewportEnter={handleLoadMore}
>
<Button
ref={loadMoreRef}
compact
disabled={!playlistSongsQueryInfinite.hasNextPage}
loading={playlistSongsQueryInfinite.isFetchingNextPage}
variant="subtle"
onClick={handleLoadMore}
>
{playlistSongsQueryInfinite.hasNextPage ? 'Load more' : 'End of playlist'}
</Button>
</MotionGroup>
</ContentContainer>
);
};

View file

@ -1,79 +0,0 @@
import { forwardRef, Fragment, Ref } from 'react';
import { Group, Stack } from '@mantine/core';
import { useParams } from 'react-router';
import { Badge, Text } from '/@/renderer/components';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { LibraryHeader } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { formatDurationString } from '/@/renderer/utils';
import { LibraryItem } from '/@/renderer/api/types';
import { useCurrentServer } from '../../../store/auth.store';
interface PlaylistDetailHeaderProps {
background: string;
imagePlaceholderUrl?: string | null;
imageUrl?: string | null;
}
export const PlaylistDetailHeader = forwardRef(
(
{ background, imageUrl, imagePlaceholderUrl }: PlaylistDetailHeaderProps,
ref: Ref<HTMLDivElement>,
) => {
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const metadataItems = [
{
id: 'songCount',
secondary: false,
value: `${detailQuery?.data?.songCount || 0} songs`,
},
{
id: 'duration',
secondary: true,
value:
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
];
const isSmartPlaylist = detailQuery?.data?.rules;
return (
<Stack>
<LibraryHeader
ref={ref}
background={background}
imagePlaceholderUrl={imagePlaceholderUrl}
imageUrl={imageUrl}
item={{ route: AppRoute.PLAYLISTS, type: LibraryItem.PLAYLIST }}
title={detailQuery?.data?.name || ''}
>
<Stack>
<Group spacing="sm">
{metadataItems.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
</Fragment>
))}
{isSmartPlaylist && (
<>
<Text $noSelect></Text>
<Badge
radius="sm"
size="md"
>
Smart Playlist
</Badge>
</>
)}
</Group>
<Text lineClamp={3}>{detailQuery?.data?.description}</Text>
</Stack>
</LibraryHeader>
</Stack>
);
},
);

View file

@ -44,15 +44,16 @@ import {
useSetPlaylistDetailTablePagination, useSetPlaylistDetailTablePagination,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { ListDisplayType } from '/@/renderer/types'; import { ListDisplayType, ServerType } from '/@/renderer/types';
import { useAppFocus } from '/@/renderer/hooks'; import { useAppFocus } from '/@/renderer/hooks';
import { toast } from '/@/renderer/components'; import { toast } from '/@/renderer/components';
interface PlaylistDetailContentProps { interface PlaylistDetailContentProps {
songs?: Song[];
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailContentProps) => { export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetailContentProps) => {
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const status = useCurrentStatus(); const status = useCurrentStatus();
@ -85,7 +86,12 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED; const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
const iSClientSide = server?.type === ServerType.SUBSONIC;
const checkPlaylistList = usePlaylistSongList({ const checkPlaylistList = usePlaylistSongList({
options: {
enabled: !iSClientSide,
},
query: { query: {
id: playlistId, id: playlistId,
limit: 1, limit: 1,
@ -101,44 +107,51 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
const onGridReady = useCallback( const onGridReady = useCallback(
(params: GridReadyEvent) => { (params: GridReadyEvent) => {
const dataSource: IDatasource = { if (!iSClientSide) {
getRows: async (params) => { const dataSource: IDatasource = {
const limit = params.endRow - params.startRow; getRows: async (params) => {
const startIndex = params.startRow; const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: PlaylistSongListQuery = { const query: PlaylistSongListQuery = {
id: playlistId, id: playlistId,
limit, limit,
startIndex, startIndex,
...filters, ...filters,
}; };
const queryKey = queryKeys.playlists.songList( const queryKey = queryKeys.playlists.songList(
server?.id || '', server?.id || '',
playlistId, playlistId,
query,
);
if (!server) return;
const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query, query,
}), );
);
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0); if (!server) return;
},
rowCount: undefined, const songsRes = await queryClient.fetchQuery(
}; queryKey,
params.api.setDatasource(dataSource); async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query,
}),
);
params.successCallback(
songsRes?.items || [],
songsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
}
params.api?.ensureIndexVisible(pagination.scrollOffset, 'top'); params.api?.ensureIndexVisible(pagination.scrollOffset, 'top');
}, },
[filters, pagination.scrollOffset, playlistId, queryClient, server], [filters, iSClientSide, pagination.scrollOffset, playlistId, queryClient, server],
); );
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
@ -270,6 +283,9 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
const { rowClassRules } = useCurrentSongRowStyles({ tableRef }); const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
const canDrag =
filters.sortBy === SongListSort.ID && !detailQuery?.data?.rules && !iSClientSide;
return ( return (
<> <>
<VirtualGridAutoSizerContainer> <VirtualGridAutoSizerContainer>
@ -289,16 +305,17 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
status, status,
}} }}
getRowId={(data) => data.data.uniqueId} getRowId={(data) => data.data.uniqueId}
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100} infiniteInitialRowCount={
iSClientSide ? undefined : checkPlaylistList.data?.totalRecordCount || 100
}
pagination={isPaginationEnabled} pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled} paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={pagination.itemsPerPage || 100} paginationPageSize={pagination.itemsPerPage || 100}
rowClassRules={rowClassRules} rowClassRules={rowClassRules}
rowDragEntireRow={ rowData={songs}
filters.sortBy === SongListSort.ID && !detailQuery?.data?.rules rowDragEntireRow={canDrag}
}
rowHeight={page.table.rowHeight || 40} rowHeight={page.table.rowHeight || 40}
rowModelType="infinite" rowModelType={iSClientSide ? 'clientSide' : 'infinite'}
onBodyScrollEnd={handleScroll} onBodyScrollEnd={handleScroll}
onCellContextMenu={handleContextMenu} onCellContextMenu={handleContextMenu}
onColumnMoved={handleColumnChange} onColumnMoved={handleColumnChange}

View file

@ -195,6 +195,68 @@ const FILTERS = {
value: SongListSort.YEAR, value: SongListSort.YEAR,
}, },
], ],
subsonic: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: SongListSort.ID,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.album', { postProcess: 'titleCase' }),
value: SongListSort.ALBUM,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: SongListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
value: SongListSort.ARTIST,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
value: SongListSort.DURATION,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
value: SongListSort.FAVORITED,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.genre', { postProcess: 'titleCase' }),
value: SongListSort.GENRE,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: SongListSort.NAME,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
value: SongListSort.RATING,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: SongListSort.RECENTLY_ADDED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
value: SongListSort.RECENTLY_PLAYED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
value: SongListSort.YEAR,
},
],
}; };
interface PlaylistDetailSongListHeaderFiltersProps { interface PlaylistDetailSongListHeaderFiltersProps {
@ -241,43 +303,55 @@ export const PlaylistDetailSongListHeaderFilters = ({
const handleFilterChange = useCallback( const handleFilterChange = useCallback(
async (filters: SongListFilter) => { async (filters: SongListFilter) => {
const dataSource: IDatasource = { if (server?.type !== ServerType.SUBSONIC) {
getRows: async (params) => { const dataSource: IDatasource = {
const limit = params.endRow - params.startRow; getRows: async (params) => {
const startIndex = params.startRow; const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, { const queryKey = queryKeys.playlists.songList(
id: playlistId, server?.id || '',
limit, playlistId,
startIndex, {
...filters, id: playlistId,
}); limit,
startIndex,
...filters,
},
);
const songsRes = await queryClient.fetchQuery( const songsRes = await queryClient.fetchQuery(
queryKey, queryKey,
async ({ signal }) => async ({ signal }) =>
api.controller.getPlaylistSongList({ api.controller.getPlaylistSongList({
apiClientProps: { apiClientProps: {
server, server,
signal, signal,
}, },
query: { query: {
id: playlistId, id: playlistId,
limit, limit,
startIndex, startIndex,
...filters, ...filters,
}, },
}), }),
{ cacheTime: 1000 * 60 * 1 }, { cacheTime: 1000 * 60 * 1 },
); );
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0); params.successCallback(
}, songsRes?.items || [],
rowCount: undefined, songsRes?.totalRecordCount || 0,
}; );
tableRef.current?.api.setDatasource(dataSource); },
tableRef.current?.api.purgeInfiniteCache(); rowCount: undefined,
tableRef.current?.api.ensureIndexVisible(0, 'top'); };
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
} else {
tableRef.current?.api.redrawRows();
tableRef.current?.api.ensureIndexVisible(0, 'top');
}
if (page.display === ListDisplayType.TABLE_PAGINATED) { if (page.display === ListDisplayType.TABLE_PAGINATED) {
setPagination({ data: { currentPage: 0 } }); setPagination({ data: { currentPage: 0 } });

View file

@ -21,7 +21,7 @@ import {
} from '/@/renderer/components/virtual-grid'; } from '/@/renderer/components/virtual-grid';
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useGeneralSettings, useListStoreByKey } from '/@/renderer/store'; import { useCurrentServer, useListStoreByKey } from '/@/renderer/store';
import { CardRow, ListDisplayType } from '/@/renderer/types'; import { CardRow, ListDisplayType } from '/@/renderer/types';
import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite'; import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite';
@ -35,15 +35,12 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const server = useCurrentServer(); const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const { display, grid, filter } = useListStoreByKey({ key: pageKey }); const { display, grid, filter } = useListStoreByKey<PlaylistListQuery>({ key: pageKey });
const { setGrid } = useListStoreActions(); const { setGrid } = useListStoreActions();
const { defaultFullPlaylist } = useGeneralSettings();
const handleFavorite = useHandleFavorite({ gridRef, server }); const handleFavorite = useHandleFavorite({ gridRef, server });
const cardRows = useMemo(() => { const cardRows = useMemo(() => {
const rows: CardRow<Playlist>[] = defaultFullPlaylist const rows: CardRow<Playlist>[] = [PLAYLIST_CARD_ROWS.nameFull];
? [PLAYLIST_CARD_ROWS.nameFull]
: [PLAYLIST_CARD_ROWS.name];
switch (filter.sortBy) { switch (filter.sortBy) {
case PlaylistListSort.DURATION: case PlaylistListSort.DURATION:
@ -66,7 +63,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
} }
return rows; return rows;
}, [defaultFullPlaylist, filter.sortBy]); }, [filter.sortBy]);
const handleGridScroll = useCallback( const handleGridScroll = useCallback(
(e: ListOnScrollProps) => { (e: ListOnScrollProps) => {
@ -116,9 +113,9 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
const query: PlaylistListQuery = { const query: PlaylistListQuery = {
limit: take, limit: take,
startIndex: skip,
...filter, ...filter,
_custom: {}, _custom: {},
startIndex: skip,
}; };
const queryKey = queryKeys.playlists.list(server?.id || '', query); const queryKey = queryKeys.playlists.list(server?.id || '', query);
@ -160,9 +157,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
loading={itemCount === undefined || itemCount === null} loading={itemCount === undefined || itemCount === null}
minimumBatchSize={40} minimumBatchSize={40}
route={{ route={{
route: defaultFullPlaylist route: AppRoute.PLAYLISTS_DETAIL_SONGS,
? AppRoute.PLAYLISTS_DETAIL_SONGS
: AppRoute.PLAYLISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }], slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }],
}} }}
width={width} width={width}

View file

@ -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 {
@ -86,7 +118,7 @@ export const PlaylistListHeaderFilters = ({
const server = useCurrentServer(); const server = useCurrentServer();
const { setFilter, setTable, setTablePagination, setGrid, setDisplayType } = const { setFilter, setTable, setTablePagination, setGrid, setDisplayType } =
useListStoreActions(); useListStoreActions();
const { display, filter, table, grid } = useListStoreByKey({ key: pageKey }); const { display, filter, table, grid } = useListStoreByKey<PlaylistListQuery>({ key: pageKey });
const cq = useContainerQuery(); const cq = useContainerQuery();
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;

View file

@ -12,7 +12,7 @@ import { PlaylistListFilter, useCurrentServer } from '/@/renderer/store';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RiFileAddFill } from 'react-icons/ri'; import { RiFileAddFill } from 'react-icons/ri';
import { LibraryItem, ServerType } from '/@/renderer/api/types'; import { LibraryItem, PlaylistListQuery, ServerType } from '/@/renderer/api/types';
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh'; import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
interface PlaylistListHeaderProps { interface PlaylistListHeaderProps {
@ -37,8 +37,9 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
}); });
}; };
const { filter, refresh, search } = useDisplayRefresh({ const { filter, refresh, search } = useDisplayRefresh<PlaylistListQuery>({
gridRef, gridRef,
itemCount,
itemType: LibraryItem.PLAYLIST, itemType: LibraryItem.PLAYLIST,
server, server,
tableRef, tableRef,

View file

@ -8,7 +8,7 @@ import { VirtualTable } from '/@/renderer/components/virtual-table';
import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table'; import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table';
import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
interface PlaylistListTableViewProps { interface PlaylistListTableViewProps {
itemCount?: number; itemCount?: number;
@ -18,16 +18,11 @@ interface PlaylistListTableViewProps {
export const PlaylistListTableView = ({ tableRef, itemCount }: PlaylistListTableViewProps) => { export const PlaylistListTableView = ({ tableRef, itemCount }: PlaylistListTableViewProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const server = useCurrentServer(); const server = useCurrentServer();
const { defaultFullPlaylist } = useGeneralSettings();
const pageKey = 'playlist'; const pageKey = 'playlist';
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => { const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
if (!e.data) return; if (!e.data) return;
if (defaultFullPlaylist) { navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }));
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }));
} else {
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
}
}; };
const tableProps = useVirtualTable({ const tableProps = useVirtualTable({

View file

@ -1,6 +1,6 @@
import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import type { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/renderer/api/types'; import type { PlaylistSongListQuery } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query'; import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store'; import { getServerById } from '/@/renderer/store';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
@ -22,32 +22,3 @@ export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>)
...options, ...options,
}); });
}; };
export const usePlaylistSongListInfinite = (args: QueryHookArgs<PlaylistSongListQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useInfiniteQuery({
enabled: !!server,
getNextPageParam: (lastPage: PlaylistSongListResponse | undefined, pages) => {
if (!lastPage?.items) return undefined;
if (lastPage?.items?.length >= (query?.limit || 50)) {
return pages?.length;
}
return undefined;
},
queryFn: ({ pageParam = 0, signal }) => {
return api.controller.getPlaylistSongList({
apiClientProps: { server, signal },
query: {
...query,
limit: query.limit || 50,
startIndex: pageParam * (query.limit || 50),
},
});
},
queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
...options,
});
};

View file

@ -1,77 +0,0 @@
import { useRef } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useParams } from 'react-router';
import { LibraryItem } from '/@/renderer/api/types';
import { NativeScrollArea, Spinner } from '/@/renderer/components';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { PlaylistDetailContent } from '/@/renderer/features/playlists/components/playlist-detail-content';
import { PlaylistDetailHeader } from '/@/renderer/features/playlists/components/playlist-detail-header';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useFastAverageColor } from '/@/renderer/hooks';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { useCurrentServer } from '../../../store/auth.store';
const PlaylistDetailRoute = () => {
const tableRef = useRef<AgGridReactType | null>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const { color: background, colorId } = useFastAverageColor({
algorithm: 'sqrt',
id: playlistId,
src: detailQuery?.data?.imageUrl,
srcLoaded: !detailQuery?.isLoading,
});
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
playType: playButtonBehavior,
});
};
if (!background || colorId !== playlistId) {
return <Spinner container />;
}
return (
<AnimatedPage key={`playlist-detail-${playlistId}`}>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
backgroundColor: background,
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
<LibraryHeaderBar.Title>
{detailQuery?.data?.name}
</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
offset: 200,
target: headerRef,
}}
>
<PlaylistDetailHeader
ref={headerRef}
background={background}
imagePlaceholderUrl={detailQuery?.data?.imageUrl}
imageUrl={detailQuery?.data?.imageUrl}
/>
<PlaylistDetailContent tableRef={tableRef} />
</NativeScrollArea>
</AnimatedPage>
);
};
export default PlaylistDetailRoute;

View file

@ -144,10 +144,6 @@ const PlaylistDetailSongListRoute = () => {
}; };
const itemCountCheck = usePlaylistSongList({ const itemCountCheck = usePlaylistSongList({
options: {
cacheTime: 1000 * 60 * 60 * 2,
staleTime: 1000 * 60 * 60 * 2,
},
query: { query: {
id: playlistId, id: playlistId,
limit: 1, limit: 1,
@ -157,10 +153,7 @@ const PlaylistDetailSongListRoute = () => {
serverId: server?.id, serverId: server?.id,
}); });
const itemCount = const itemCount = itemCountCheck.data?.totalRecordCount || itemCountCheck.data?.items.length;
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
return ( return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}> <AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
@ -207,7 +200,12 @@ const PlaylistDetailSongListRoute = () => {
</Paper> </Paper>
</Box> </Box>
)} )}
<PlaylistDetailSongListContent tableRef={tableRef} /> <PlaylistDetailSongListContent
songs={
server?.type === ServerType.SUBSONIC ? itemCountCheck.data?.items : undefined
}
tableRef={tableRef}
/>
</AnimatedPage> </AnimatedPage>
); );
}; };

View file

@ -1,7 +1,7 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { PlaylistListSort, SortOrder } from '/@/renderer/api/types'; import { PlaylistListSort, PlaylistSongListQuery, SortOrder } from '/@/renderer/api/types';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ListContext } from '/@/renderer/context/list-context'; import { ListContext } from '/@/renderer/context/list-context';
import { PlaylistListContent } from '/@/renderer/features/playlists/components/playlist-list-content'; import { PlaylistListContent } from '/@/renderer/features/playlists/components/playlist-list-content';
@ -16,7 +16,7 @@ const PlaylistListRoute = () => {
const server = useCurrentServer(); const server = useCurrentServer();
const { playlistId } = useParams(); const { playlistId } = useParams();
const pageKey = 'playlist'; const pageKey = 'playlist';
const { filter } = useListStoreByKey({ key: pageKey }); const { filter } = useListStoreByKey<PlaylistSongListQuery>({ key: pageKey });
const itemCountCheck = usePlaylistList({ const itemCountCheck = usePlaylistList({
options: { options: {

View file

@ -5,7 +5,12 @@ import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generatePath, Link, useParams, useSearchParams } from 'react-router-dom'; import { generatePath, Link, useParams, useSearchParams } from 'react-router-dom';
import { useCurrentServer } from '../../../store/auth.store'; import { useCurrentServer } from '../../../store/auth.store';
import { LibraryItem } from '/@/renderer/api/types'; import {
AlbumArtistListQuery,
AlbumListQuery,
LibraryItem,
SongListQuery,
} from '/@/renderer/api/types';
import { Button, PageHeader, SearchInput } from '/@/renderer/components'; import { Button, PageHeader, SearchInput } from '/@/renderer/components';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks'; import { useContainerQuery } from '/@/renderer/hooks';
@ -24,7 +29,9 @@ export const SearchHeader = ({ tableRef, navigationId }: SearchHeaderProps) => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const cq = useContainerQuery(); const cq = useContainerQuery();
const server = useCurrentServer(); const server = useCurrentServer();
const { filter } = useListStoreByKey({ key: itemType }); const { filter } = useListStoreByKey<AlbumListQuery | AlbumArtistListQuery | SongListQuery>({
key: itemType,
});
const { handleRefreshTable } = useListFilterRefresh({ const { handleRefreshTable } = useListFilterRefresh({
itemType, itemType,

View file

@ -17,7 +17,7 @@ const localSettings = isElectron() ? window.electron.localSettings : null;
const SERVER_TYPES = [ const SERVER_TYPES = [
{ label: 'Jellyfin', value: ServerType.JELLYFIN }, { label: 'Jellyfin', value: ServerType.JELLYFIN },
{ label: 'Navidrome', value: ServerType.NAVIDROME }, { label: 'Navidrome', value: ServerType.NAVIDROME },
// { label: 'Subsonic', value: ServerType.SUBSONIC }, { label: 'Subsonic', value: ServerType.SUBSONIC },
]; ];
interface AddServerFormProps { interface AddServerFormProps {

View file

@ -375,28 +375,6 @@ export const ControlSettings = () => {
isHidden: !isElectron(), isHidden: !isElectron(),
title: t('setting.savePlayQueue', { postProcess: 'sentenceCase' }), title: t('setting.savePlayQueue', { postProcess: 'sentenceCase' }),
}, },
{
control: (
<Switch
aria-label="Go to playlist songs page by default"
defaultChecked={settings.defaultFullPlaylist}
onChange={(e) =>
setSettings({
general: {
...settings,
defaultFullPlaylist: e.currentTarget.checked,
},
})
}
/>
),
description: t('setting.skipPlaylistPage', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.skipPlaylistPage', { postProcess: 'sentenceCase' }),
},
{ {
control: ( control: (
<Switch <Switch

View file

@ -35,7 +35,7 @@ export const useSetRating = (args: MutationHookArgs) => {
mutationFn: (args) => { mutationFn: (args) => {
const server = getServerById(args.serverId); const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found'); if (!server) throw new Error('Server not found');
return api.controller.updateRating({ ...args, apiClientProps: { server } }); return api.controller.setRating({ ...args, apiClientProps: { server } });
}, },
onError: (_error, _variables, context) => { onError: (_error, _variables, context) => {
for (const item of context?.previous?.items || []) { for (const item of context?.previous?.items || []) {

View file

@ -11,7 +11,7 @@ import {
} from 'react-icons/ri'; } from 'react-icons/ri';
import { generatePath } from 'react-router'; import { generatePath } from 'react-router';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { LibraryItem, Playlist } from '/@/renderer/api/types'; import { LibraryItem, Playlist, PlaylistListSort, SortOrder } from '/@/renderer/api/types';
import { Button, Text } from '/@/renderer/components'; import { Button, Text } from '/@/renderer/components';
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlaylistList } from '/@/renderer/features/playlists'; import { usePlaylistList } from '/@/renderer/features/playlists';
@ -24,10 +24,6 @@ import { useCurrentServer, useGeneralSettings, useSettingsStoreActions } from '/
import { openContextMenu } from '/@/renderer/features/context-menu'; import { openContextMenu } from '/@/renderer/features/context-menu';
import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
interface SidebarPlaylistListProps {
data: ReturnType<typeof usePlaylistList>['data'];
}
const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => { const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -66,11 +62,7 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
} }
const path = data?.items[index].id const path = data?.items[index].id
? data.defaultFullPlaylist ? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id })
? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id })
: generatePath(AppRoute.PLAYLISTS_DETAIL, {
playlistId: data?.items[index].id,
})
: undefined; : undefined;
return ( return (
@ -181,12 +173,21 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
); );
}; };
export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => { export const SidebarPlaylistList = () => {
const { isScrollbarHidden, hideScrollbarElementProps } = useHideScrollbar(0); const { isScrollbarHidden, hideScrollbarElementProps } = useHideScrollbar(0);
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const { defaultFullPlaylist, sidebarCollapseShared } = useGeneralSettings(); const { sidebarCollapseShared } = useGeneralSettings();
const { toggleSidebarCollapseShare } = useSettingsStoreActions(); const { toggleSidebarCollapseShare } = useSettingsStoreActions();
const { type, username } = useCurrentServer() || {}; const server = useCurrentServer();
const playlistsQuery = usePlaylistList({
query: {
sortBy: PlaylistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server?.id,
});
const [rect, setRect] = useState({ const [rect, setRect] = useState({
height: 0, height: 0,
@ -208,10 +209,12 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
[handlePlayQueueAdd], [handlePlayQueueAdd],
); );
const memoizedItemData = useMemo(() => { const data = playlistsQuery.data;
const base = { defaultFullPlaylist, handlePlay: handlePlayPlaylist };
if (!type || !username || !data?.items) { const memoizedItemData = useMemo(() => {
const base = { handlePlay: handlePlayPlaylist };
if (!server?.type || !server?.username || !data?.items) {
return { ...base, items: data?.items }; return { ...base, items: data?.items };
} }
@ -219,7 +222,7 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
const shared: Playlist[] = []; const shared: Playlist[] = [];
for (const playlist of data.items) { for (const playlist of data.items) {
if (playlist.owner && playlist.owner !== username) { if (playlist.owner && playlist.owner !== server.username) {
shared.push(playlist); shared.push(playlist);
} else { } else {
owned.push(playlist); owned.push(playlist);
@ -234,12 +237,11 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
return { ...base, items: final }; return { ...base, items: final };
}, [ }, [
sidebarCollapseShared,
data?.items, data?.items,
defaultFullPlaylist,
handlePlayPlaylist, handlePlayPlaylist,
type, server?.type,
username, server?.username,
sidebarCollapseShared,
toggleSidebarCollapseShare, toggleSidebarCollapseShare,
]); ]);

View file

@ -11,9 +11,9 @@ import {
useGeneralSettings, useGeneralSettings,
useWindowSettings, useWindowSettings,
} from '../../../store/settings.store'; } from '../../../store/settings.store';
import { PlaylistListSort, ServerType, SortOrder } from '/@/renderer/api/types'; import { ServerType } from '/@/renderer/api/types';
import { Button, MotionStack, Spinner, Tooltip } from '/@/renderer/components'; import { Button, MotionStack, Tooltip } from '/@/renderer/components';
import { CreatePlaylistForm, usePlaylistList } from '/@/renderer/features/playlists'; import { CreatePlaylistForm } from '/@/renderer/features/playlists';
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar'; import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon'; import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item'; import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
@ -110,15 +110,6 @@ export const Sidebar = () => {
}); });
}; };
const playlistsQuery = usePlaylistList({
query: {
sortBy: PlaylistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server?.id,
});
const setFullScreenPlayerStore = useSetFullScreenPlayerStore(); const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore(); const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const expandFullScreenPlayer = () => { const expandFullScreenPlayer = () => {
@ -198,7 +189,6 @@ export const Sidebar = () => {
> >
{t('page.sidebar.playlists', { postProcess: 'titleCase' })} {t('page.sidebar.playlists', { postProcess: 'titleCase' })}
</Box> </Box>
{playlistsQuery.isLoading && <Spinner />}
</Group> </Group>
<Group spacing="sm"> <Group spacing="sm">
<Button <Button
@ -233,7 +223,7 @@ export const Sidebar = () => {
</Button> </Button>
</Group> </Group>
</Group> </Group>
<SidebarPlaylistList data={playlistsQuery.data} /> <SidebarPlaylistList />
</> </>
)} )}
</MotionStack> </MotionStack>

View file

@ -1,7 +1,7 @@
import { ChangeEvent, useMemo } from 'react'; import { ChangeEvent, useMemo } from 'react';
import { Divider, Group, Stack } from '@mantine/core'; import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components'; import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres'; import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
@ -22,7 +22,7 @@ export const JellyfinSongFilters = ({
}: JellyfinSongFiltersProps) => { }: JellyfinSongFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setFilter } = useListStoreActions(); const { setFilter } = useListStoreActions();
const filter = useListFilterByKey({ key: pageKey }); const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
const isGenrePage = customFilters?._custom?.jellyfin?.GenreIds !== undefined; const isGenrePage = customFilters?._custom?.jellyfin?.GenreIds !== undefined;
@ -61,16 +61,16 @@ export const JellyfinSongFilters = ({
jellyfin: { jellyfin: {
...filter?._custom?.jellyfin, ...filter?._custom?.jellyfin,
IncludeItemTypes: 'Audio', IncludeItemTypes: 'Audio',
IsFavorite: e.currentTarget.checked ? true : undefined,
}, },
}, },
favorite: e.currentTarget.checked ? true : undefined,
}, },
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
key: pageKey, key: pageKey,
}) as SongListFilter; }) as SongListFilter;
onFilterChange(updatedFilters); onFilterChange(updatedFilters);
}, },
value: filter?._custom?.jellyfin?.IsFavorite, value: filter.favorite,
}, },
]; ];
@ -84,9 +84,9 @@ export const JellyfinSongFilters = ({
jellyfin: { jellyfin: {
...filter?._custom?.jellyfin, ...filter?._custom?.jellyfin,
IncludeItemTypes: 'Audio', IncludeItemTypes: 'Audio',
minYear: e === '' ? undefined : (e as number),
}, },
}, },
minYear: e === '' ? undefined : (e as number),
}, },
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
key: pageKey, key: pageKey,
@ -104,9 +104,9 @@ export const JellyfinSongFilters = ({
jellyfin: { jellyfin: {
...filter?._custom?.jellyfin, ...filter?._custom?.jellyfin,
IncludeItemTypes: 'Audio', IncludeItemTypes: 'Audio',
maxYear: e === '' ? undefined : (e as number),
}, },
}, },
maxYear: e === '' ? undefined : (e as number),
}, },
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
key: pageKey, key: pageKey,
@ -115,7 +115,6 @@ export const JellyfinSongFilters = ({
}, 500); }, 500);
const handleGenresFilter = debounce((e: string[] | undefined) => { const handleGenresFilter = debounce((e: string[] | undefined) => {
const genreFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
@ -123,10 +122,10 @@ export const JellyfinSongFilters = ({
...filter?._custom, ...filter?._custom,
jellyfin: { jellyfin: {
...filter?._custom?.jellyfin, ...filter?._custom?.jellyfin,
GenreIds: genreFilterString,
IncludeItemTypes: 'Audio', IncludeItemTypes: 'Audio',
}, },
}, },
genreIds: e,
}, },
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
key: pageKey, key: pageKey,
@ -151,18 +150,19 @@ export const JellyfinSongFilters = ({
<Divider my="0.5rem" /> <Divider my="0.5rem" />
<Group grow> <Group grow>
<NumberInput <NumberInput
required defaultValue={filter?.minYear}
defaultValue={filter?._custom?.jellyfin?.minYear}
label={t('filter.fromYear', { postProcess: 'sentenceCase' })} label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={2300} max={2300}
min={1700} min={1700}
required={!!filter?.minYear}
onChange={handleMinYearFilter} onChange={handleMinYearFilter}
/> />
<NumberInput <NumberInput
defaultValue={filter?._custom?.jellyfin?.maxYear} defaultValue={filter?.maxYear}
label={t('filter.toYear', { postProcess: 'sentenceCase' })} label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={2300} max={2300}
min={1700} min={1700}
required={!!filter?.minYear}
onChange={handleMaxYearFilter} onChange={handleMaxYearFilter}
/> />
</Group> </Group>

View file

@ -1,7 +1,7 @@
import { ChangeEvent, useMemo } from 'react'; import { ChangeEvent, useMemo } from 'react';
import { Divider, Group, Stack } from '@mantine/core'; import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
import { NumberInput, Select, Switch, Text } from '/@/renderer/components'; import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres'; import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
@ -22,9 +22,9 @@ export const NavidromeSongFilters = ({
}: NavidromeSongFiltersProps) => { }: NavidromeSongFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setFilter } = useListStoreActions(); const { setFilter } = useListStoreActions();
const filter = useListFilterByKey({ key: pageKey }); const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
const isGenrePage = customFilters?._custom?.navidrome?.genre_id !== undefined; const isGenrePage = customFilters?.genreIds !== undefined;
const genreListQuery = useGenreList({ const genreListQuery = useGenreList({
query: { query: {
@ -47,12 +47,8 @@ export const NavidromeSongFilters = ({
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: { _custom: filter._custom,
...filter._custom, genreIds: e ? [e] : undefined,
navidrome: {
genre_id: e || undefined,
},
},
}, },
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
key: pageKey, key: pageKey,
@ -68,12 +64,8 @@ export const NavidromeSongFilters = ({
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: { _custom: filter._custom,
...filter._custom, favorite: e.currentTarget.checked ? true : undefined,
navidrome: {
starred: e.currentTarget.checked ? true : undefined,
},
},
}, },
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
key: pageKey, key: pageKey,
@ -81,7 +73,7 @@ export const NavidromeSongFilters = ({
onFilterChange(updatedFilters); onFilterChange(updatedFilters);
}, },
value: filter._custom?.navidrome?.starred, value: filter.favorite,
}, },
]; ];
@ -133,7 +125,7 @@ export const NavidromeSongFilters = ({
clearable clearable
searchable searchable
data={genreList} data={genreList}
defaultValue={filter._custom?.navidrome?.genre_id} defaultValue={filter.genreIds ? filter.genreIds[0] : undefined}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })} label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
width={150} width={150}
onChange={handleGenresFilter} onChange={handleGenresFilter}

View file

@ -36,7 +36,7 @@ export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps)
const server = useCurrentServer(); const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const { pageKey, customFilters, id } = useListContext(); const { pageKey, customFilters, id } = useListContext();
const { grid, display, filter } = useListStoreByKey({ key: pageKey }); const { grid, display, filter } = useListStoreByKey<SongListQuery>({ key: pageKey });
const { setGrid } = useListStoreActions(); const { setGrid } = useListStoreActions();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -174,9 +174,9 @@ export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps)
const query: SongListQuery = { const query: SongListQuery = {
imageSize: 250, imageSize: 250,
limit: take, limit: take,
startIndex: skip,
...filter, ...filter,
...customFilters, ...customFilters,
startIndex: skip,
}; };
const queryKey = queryKeys.songs.list(server?.id || '', query, id); const queryKey = queryKeys.songs.list(server?.id || '', query, id);

View file

@ -15,7 +15,13 @@ import {
} from 'react-icons/ri'; } from 'react-icons/ri';
import { useListStoreByKey } from '../../../store/list.store'; import { useListStoreByKey } from '../../../store/list.store';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem, ServerType, SongListSort, SortOrder } from '/@/renderer/api/types'; import {
LibraryItem,
ServerType,
SongListQuery,
SongListSort,
SortOrder,
} from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
@ -29,6 +35,7 @@ import { queryClient } from '/@/renderer/lib/react-query';
import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store'; import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types'; import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n'; import i18n from '/@/i18n/i18n';
import { SubsonicSongFilters } from '/@/renderer/features/songs/components/subsonic-song-filter';
const FILTERS = { const FILTERS = {
jellyfin: [ jellyfin: [
@ -165,25 +172,39 @@ 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;
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFiltersProps) => { export const SongListHeaderFilters = ({
gridRef,
itemCount,
tableRef,
}: SongListHeaderFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const server = useCurrentServer(); const server = useCurrentServer();
const { pageKey, handlePlay, customFilters } = useListContext(); const { pageKey, handlePlay, customFilters } = useListContext();
const { display, table, filter, grid } = useListStoreByKey({ const { display, table, filter, grid } = useListStoreByKey<SongListQuery>({
filter: customFilters, filter: customFilters,
key: pageKey, key: pageKey,
}); });
const { setFilter, setGrid, setTable, setTablePagination, setDisplayType } = const { setFilter, setGrid, setTable, setTablePagination, setDisplayType } =
useListStoreActions(); useListStoreActions();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
server, server,
}); });
@ -392,25 +413,32 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
}; };
const handleOpenFiltersModal = () => { const handleOpenFiltersModal = () => {
let FilterComponent;
switch (server?.type) {
case ServerType.NAVIDROME:
FilterComponent = NavidromeSongFilters;
break;
case ServerType.JELLYFIN:
FilterComponent = JellyfinSongFilters;
break;
case ServerType.SUBSONIC:
FilterComponent = SubsonicSongFilters;
break;
}
if (!FilterComponent) {
return;
}
openModal({ openModal({
children: ( children: (
<> <FilterComponent
{server?.type === ServerType.NAVIDROME ? ( customFilters={customFilters}
<NavidromeSongFilters pageKey={pageKey}
customFilters={customFilters} serverId={server?.id}
pageKey={pageKey} onFilterChange={onFilterChange}
serverId={server?.id} />
onFilterChange={onFilterChange}
/>
) : (
<JellyfinSongFilters
customFilters={customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
)}
</>
), ),
title: 'Song Filters', title: 'Song Filters',
}); });
@ -429,8 +457,16 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
.filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio .filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio
.some((value) => value !== undefined); .some((value) => value !== undefined);
return isNavidromeFilterApplied || isJellyfinFilterApplied; const isGenericFilterApplied = filter?.favorite || filter?.genreIds?.length;
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]);
return isNavidromeFilterApplied || isJellyfinFilterApplied || isGenericFilterApplied;
}, [
filter._custom?.jellyfin,
filter._custom?.navidrome,
filter?.favorite,
filter?.genreIds?.length,
server?.type,
]);
const isFolderFilterApplied = useMemo(() => { const isFolderFilterApplied = useMemo(() => {
return filter.musicFolderId !== undefined; return filter.musicFolderId !== undefined;
@ -467,11 +503,15 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
))} ))}
</DropdownMenu.Dropdown> </DropdownMenu.Dropdown>
</DropdownMenu> </DropdownMenu>
<Divider orientation="vertical" /> {server?.type !== ServerType.SUBSONIC && (
<OrderToggleButton <>
sortOrder={filter.sortOrder} <Divider orientation="vertical" />
onToggle={handleToggleSortOrder} <OrderToggleButton
/> sortOrder={filter.sortOrder}
onToggle={handleToggleSortOrder}
/>
</>
)}
{server?.type === ServerType.JELLYFIN && ( {server?.type === ServerType.JELLYFIN && (
<> <>
<Divider orientation="vertical" /> <Divider orientation="vertical" />

View file

@ -3,7 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { Flex, Group, Stack } from '@mantine/core'; import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LibraryItem } from '/@/renderer/api/types'; import { LibraryItem, SongListQuery } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components'; import { PageHeader, SearchInput } from '/@/renderer/components';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters'; import { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters';
@ -33,12 +33,15 @@ export const SongListHeader = ({
const cq = useContainerQuery(); const cq = useContainerQuery();
const genreRef = useRef<string>(); const genreRef = useRef<string>();
const { customFilters, filter, handlePlay, refresh, search } = useDisplayRefresh({ const { customFilters, filter, handlePlay, refresh, search } = useDisplayRefresh<SongListQuery>(
gridRef, {
itemType: LibraryItem.SONG, gridRef,
server, itemCount,
tableRef, itemType: LibraryItem.SONG,
}); server,
tableRef,
},
);
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => { const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = search(e) as SongListFilter; const updatedFilters = search(e) as SongListFilter;
@ -96,6 +99,7 @@ export const SongListHeader = ({
<FilterBar> <FilterBar>
<SongListHeaderFilters <SongListHeaderFilters
gridRef={gridRef} gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef} tableRef={tableRef}
/> />
</FilterBar> </FilterBar>

View file

@ -0,0 +1,112 @@
import { ChangeEvent, useMemo } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
import { Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
interface SubsonicSongFiltersProps {
customFilters?: Partial<SongListFilter>;
onFilterChange: (filters: SongListFilter) => void;
pageKey: string;
serverId?: string;
}
export const SubsonicSongFilters = ({
customFilters,
onFilterChange,
pageKey,
serverId,
}: SubsonicSongFiltersProps) => {
const { t } = useTranslation();
const { setFilter } = useListStoreActions();
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
const isGenrePage = customFilters?.genreIds !== undefined;
const genreListQuery = useGenreList({
query: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
});
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.items.map((genre) => ({
label: genre.name,
value: genre.id,
}));
}, [genreListQuery.data]);
const handleGenresFilter = debounce((e: string | null) => {
const updatedFilters = setFilter({
customFilters,
data: {
genreIds: e ? [e] : undefined,
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
onFilterChange(updatedFilters);
}, 250);
const toggleFilters = [
{
disabled: filter.genreIds !== undefined || isGenrePage || !!filter.searchTerm,
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
favorite: e.target.checked,
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
onFilterChange(updatedFilters);
},
value: filter.favorite,
},
];
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
key={`ss-filter-${filter.label}`}
position="apart"
>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
disabled={filter.disabled}
size="xs"
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
{!isGenrePage && (
<Select
clearable
searchable
data={genreList}
defaultValue={filter.genreIds ? filter.genreIds[0] : undefined}
disabled={!!filter.searchTerm}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
width={150}
onChange={handleGenresFilter}
/>
)}
</Group>
</Stack>
);
};

View file

@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { SongListQuery } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useSongListCount = (args: QueryHookArgs<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 || '',
Object.keys(query).length === 0 ? undefined : query,
),
...options,
});
};

View file

@ -10,11 +10,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 { t } = useTranslation(); const { t } = useTranslation();
@ -30,14 +30,7 @@ const TrackListRoute = () => {
const value = { const value = {
...(albumArtistId && { artistIds: [albumArtistId] }), ...(albumArtistId && { artistIds: [albumArtistId] }),
...(genreId && { ...(genreId && {
_custom: { genreIds: [genreId],
jellyfin: {
GenreIds: genreId,
},
navidrome: {
genre_id: genreId,
},
},
}), }),
}; };
@ -76,29 +69,22 @@ 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,
}, },
query: { query: songListFilter,
limit: 1,
startIndex: 0,
...songListFilter,
},
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 }) => {
if (!itemCount || itemCount === 0) return; if (!itemCount || itemCount === 0) return;
const { initialSongId, playType } = args; const { initialSongId, playType } = args;
const query: SongListQuery = { startIndex: 0, ...songListFilter }; const query: SongListQuery = { ...songListFilter, limit: itemCount, startIndex: 0 };
if (albumArtistId) { if (albumArtistId) {
handlePlayQueueAdd?.({ handlePlayQueueAdd?.({

View file

@ -11,21 +11,24 @@ import { useListStoreActions, useListStoreByKey } from '/@/renderer/store';
export type UseDisplayRefreshProps = { export type UseDisplayRefreshProps = {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>; gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} & UseHandleListFilterChangeProps; } & UseHandleListFilterChangeProps;
export const useDisplayRefresh = ({ export const useDisplayRefresh = <TFilter>({
isClientSideSort, isClientSideSort,
itemCount,
gridRef, gridRef,
itemType, itemType,
server, server,
tableRef, tableRef,
}: UseDisplayRefreshProps) => { }: UseDisplayRefreshProps) => {
const { customFilters, pageKey, handlePlay } = useListContext(); const { customFilters, pageKey, handlePlay } = useListContext();
const { display, filter } = useListStoreByKey({ key: pageKey }); const { display, filter } = useListStoreByKey<TFilter>({ key: pageKey });
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
isClientSideSort, isClientSideSort,
itemCount,
itemType, itemType,
server, server,
}); });

View file

@ -10,12 +10,16 @@ import orderBy from 'lodash/orderBy';
export interface UseHandleListFilterChangeProps { export interface UseHandleListFilterChangeProps {
isClientSideSort?: boolean; isClientSideSort?: boolean;
itemCount?: number;
itemType: LibraryItem; itemType: LibraryItem;
server: ServerListItem | null; server: ServerListItem | null;
} }
const BLOCK_SIZE = 500;
export const useListFilterRefresh = ({ export const useListFilterRefresh = ({
server, server,
itemCount,
itemType, itemType,
isClientSideSort, isClientSideSort,
}: UseHandleListFilterChangeProps) => { }: UseHandleListFilterChangeProps) => {
@ -78,7 +82,7 @@ export const useListFilterRefresh = ({
const queryKey = queryKeyFn(server?.id || '', query); const queryKey = queryKeyFn(server?.id || '', query);
const res = await queryClient.fetchQuery({ const results = await queryClient.fetchQuery({
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
return queryFn({ return queryFn({
apiClientProps: { apiClientProps: {
@ -91,18 +95,34 @@ export const useListFilterRefresh = ({
queryKey, queryKey,
}); });
if (isClientSideSort && res?.items) { if (isClientSideSort && results?.items) {
const sortedResults = orderBy( const sortedResults = orderBy(
res.items, results.items,
[(item) => String(item[filter.sortBy]).toLowerCase()], [(item) => String(item[filter.sortBy]).toLowerCase()],
filter.sortOrder === 'DESC' ? ['desc'] : ['asc'], filter.sortOrder === 'DESC' ? ['desc'] : ['asc'],
); );
params.successCallback(sortedResults || [], res?.totalRecordCount || 0); params.successCallback(
sortedResults || [],
results?.totalRecordCount || itemCount,
);
return; return;
} }
params.successCallback(res?.items || [], res?.totalRecordCount || 0); if (results?.totalRecordCount === null) {
const hasMoreRows = results?.items?.length === BLOCK_SIZE;
const lastRowIndex = hasMoreRows
? undefined
: (filter.offset || 0) + results.items.length;
params.successCallback(
results?.items || [],
hasMoreRows ? undefined : lastRowIndex,
);
return;
}
params.successCallback(results?.items || [], results?.totalRecordCount || 0);
}, },
rowCount: undefined, rowCount: undefined,
@ -112,7 +132,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(

View file

@ -17,10 +17,6 @@ const AlbumListRoute = lazy(() => import('/@/renderer/features/albums/routes/alb
const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route')); const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route'));
const PlaylistDetailRoute = lazy(
() => import('/@/renderer/features/playlists/routes/playlist-detail-route'),
);
const PlaylistDetailSongListRoute = lazy( const PlaylistDetailSongListRoute = lazy(
() => import('/@/renderer/features/playlists/routes/playlist-detail-song-list-route'), () => import('/@/renderer/features/playlists/routes/playlist-detail-song-list-route'),
); );
@ -163,11 +159,6 @@ export const AppRouter = () => {
errorElement={<RouteErrorBoundary />} errorElement={<RouteErrorBoundary />}
path={AppRoute.PLAYLISTS} path={AppRoute.PLAYLISTS}
/> />
<Route
element={<PlaylistDetailRoute />}
errorElement={<RouteErrorBoundary />}
path={AppRoute.PLAYLISTS_DETAIL}
/>
<Route <Route
element={<PlaylistDetailSongListRoute />} element={<PlaylistDetailSongListRoute />}
errorElement={<RouteErrorBoundary />} errorElement={<RouteErrorBoundary />}

View file

@ -20,7 +20,6 @@ export enum AppRoute {
NOW_PLAYING = '/now-playing', NOW_PLAYING = '/now-playing',
PLAYING = '/playing', PLAYING = '/playing',
PLAYLISTS = '/playlists', PLAYLISTS = '/playlists',
PLAYLISTS_DETAIL = '/playlists/:playlistId',
PLAYLISTS_DETAIL_SONGS = '/playlists/:playlistId/songs', PLAYLISTS_DETAIL_SONGS = '/playlists/:playlistId/songs',
SEARCH = '/search/:itemType', SEARCH = '/search/:itemType',
SERVERS = '/servers', SERVERS = '/servers',

View file

@ -627,7 +627,10 @@ export const useListStore = create<ListSlice>()(
export const useListStoreActions = () => useListStore((state) => state._actions); export const useListStoreActions = () => useListStore((state) => state._actions);
export const useListStoreByKey = <TFilter>(args: { filter?: Partial<TFilter>; key: string }) => { export const useListStoreByKey = <TFilter>(args: {
filter?: Partial<TFilter>;
key: string;
}): ListItemProps<TFilter> => {
const key = args.key as keyof ListState['item']; const key = args.key as keyof ListState['item'];
return useListStore( return useListStore(
(state) => ({ (state) => ({
@ -644,7 +647,7 @@ export const useListStoreByKey = <TFilter>(args: { filter?: Partial<TFilter>; ke
export const useListFilterByKey = <TFilter>(args: { export const useListFilterByKey = <TFilter>(args: {
filter?: Partial<TFilter> | any; filter?: Partial<TFilter> | any;
key: string; key: string;
}) => { }): TFilter => {
const key = args.key as keyof ListState['item']; const key = args.key as keyof ListState['item'];
return useListStore( return useListStore(
(state) => { (state) => {

View file

@ -224,7 +224,6 @@ export interface SettingsState {
albumBackgroundBlur: number; albumBackgroundBlur: number;
artistItems: SortableItem<ArtistItem>[]; artistItems: SortableItem<ArtistItem>[];
buttonSize: number; buttonSize: number;
defaultFullPlaylist: boolean;
disabledContextMenu: { [k in ContextMenuItemType]?: boolean }; disabledContextMenu: { [k in ContextMenuItemType]?: boolean };
doubleClickQueueAll: boolean; doubleClickQueueAll: boolean;
externalLinks: boolean; externalLinks: boolean;
@ -370,7 +369,6 @@ const initialState: SettingsState = {
albumBackgroundBlur: 6, albumBackgroundBlur: 6,
artistItems, artistItems,
buttonSize: 20, buttonSize: 20,
defaultFullPlaylist: true,
disabledContextMenu: {}, disabledContextMenu: {},
doubleClickQueueAll: true, doubleClickQueueAll: true,
externalLinks: true, externalLinks: true,