mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54b18601b8 | |||
| 0cd0032966 | |||
| d6cc6a4745 | |||
| f7fcf6c079 | |||
| 4051e9dfa3 | |||
| 5a94f70e63 | |||
| 50dd70df81 | |||
| 8493668c97 | |||
| d347221be5 | |||
| 18ec50b2a3 | |||
| 3c691d23d9 | |||
| 8ce2a99d37 | |||
| 567424011f | |||
| b2f14d7369 | |||
| 2ecafea759 | |||
| b7bbba928d | |||
| 33b522a2f3 | |||
| f8d109fce4 | |||
| 8fcf5291c4 | |||
| 3b155cc6e8 | |||
| 509627a0ad | |||
| d08d3686de | |||
| ca695ca155 |
@@ -552,7 +552,7 @@
|
||||
"column": {
|
||||
"album": "album",
|
||||
"albumArtist": "album artist",
|
||||
"albumCount": "$t(entity.album_other)",
|
||||
"albumCount": "$t(entity.album_one)",
|
||||
"artist": "$t(entity.artist_one)",
|
||||
"biography": "biography",
|
||||
"bitrate": "bitrate",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const reset = '\x1b[0m';
|
||||
|
||||
const baseLog = (errorType: 'error' | 'info' | 'success' | 'warn') => {
|
||||
let logString = '';
|
||||
switch (errorType) {
|
||||
case 'error':
|
||||
logString = '\x1b[31m[ERROR] ';
|
||||
break;
|
||||
case 'info':
|
||||
logString = '\x1b[34m[INFO] ';
|
||||
break;
|
||||
case 'success':
|
||||
logString = '\x1b[32m[SUCCESS] ';
|
||||
break;
|
||||
case 'warn':
|
||||
logString = '\x1b[33m[WARNING] ';
|
||||
break;
|
||||
default:
|
||||
logString = '\x1b[34m[INFO] ';
|
||||
break;
|
||||
}
|
||||
|
||||
return (text: string, options?: { context?: Record<string, any>; toast?: boolean }): void => {
|
||||
// const { toast } = options || {};
|
||||
const now = dayjs().toISOString();
|
||||
console.log(
|
||||
`${logString}${now}: ${text} | ${
|
||||
options?.context && JSON.stringify(options.context)
|
||||
}${reset}`,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const fsLog = {
|
||||
error: baseLog('error'),
|
||||
info: baseLog('info'),
|
||||
success: baseLog('success'),
|
||||
warn: baseLog('warn'),
|
||||
};
|
||||
+66
-189
@@ -1,100 +1,38 @@
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import { RandomSongListArgs } from './types';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller';
|
||||
import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||
import type {
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
SongListArgs,
|
||||
SongDetailArgs,
|
||||
AddToPlaylistArgs,
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistListArgs,
|
||||
SetRatingArgs,
|
||||
GenreListArgs,
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
ArtistListArgs,
|
||||
ControllerEndpoint,
|
||||
CreatePlaylistArgs,
|
||||
DeletePlaylistArgs,
|
||||
FavoriteArgs,
|
||||
GenreListArgs,
|
||||
LyricsArgs,
|
||||
MusicFolderListArgs,
|
||||
PlaylistDetailArgs,
|
||||
PlaylistListArgs,
|
||||
MusicFolderListArgs,
|
||||
PlaylistSongListArgs,
|
||||
ArtistListArgs,
|
||||
RemoveFromPlaylistArgs,
|
||||
ScrobbleArgs,
|
||||
SearchArgs,
|
||||
SetRatingArgs,
|
||||
SongDetailArgs,
|
||||
SongListArgs,
|
||||
TopSongListArgs,
|
||||
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,
|
||||
} from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
|
||||
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
export type ControllerEndpoint = Partial<{
|
||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<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>;
|
||||
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>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||
}>;
|
||||
|
||||
type ApiController = {
|
||||
jellyfin: ControllerEndpoint;
|
||||
@@ -103,110 +41,9 @@ type ApiController = {
|
||||
};
|
||||
|
||||
const endpoints: ApiController = {
|
||||
jellyfin: {
|
||||
addToPlaylist: jfController.addToPlaylist,
|
||||
authenticate: jfController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: jfController.createFavorite,
|
||||
createPlaylist: jfController.createPlaylist,
|
||||
deleteFavorite: jfController.deleteFavorite,
|
||||
deletePlaylist: jfController.deletePlaylist,
|
||||
getAlbumArtistDetail: jfController.getAlbumArtistDetail,
|
||||
getAlbumArtistList: jfController.getAlbumArtistList,
|
||||
getAlbumDetail: jfController.getAlbumDetail,
|
||||
getAlbumList: jfController.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: jfController.getGenreList,
|
||||
getLyrics: jfController.getLyrics,
|
||||
getMusicFolderList: jfController.getMusicFolderList,
|
||||
getPlaylistDetail: jfController.getPlaylistDetail,
|
||||
getPlaylistList: jfController.getPlaylistList,
|
||||
getPlaylistSongList: jfController.getPlaylistSongList,
|
||||
getRandomSongList: jfController.getRandomSongList,
|
||||
getSongDetail: jfController.getSongDetail,
|
||||
getSongList: jfController.getSongList,
|
||||
getTopSongs: jfController.getTopSongList,
|
||||
getUserList: undefined,
|
||||
removeFromPlaylist: jfController.removeFromPlaylist,
|
||||
scrobble: jfController.scrobble,
|
||||
search: jfController.search,
|
||||
setRating: undefined,
|
||||
updatePlaylist: jfController.updatePlaylist,
|
||||
},
|
||||
navidrome: {
|
||||
addToPlaylist: ndController.addToPlaylist,
|
||||
authenticate: ndController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: ssController.createFavorite,
|
||||
createPlaylist: ndController.createPlaylist,
|
||||
deleteFavorite: ssController.removeFavorite,
|
||||
deletePlaylist: ndController.deletePlaylist,
|
||||
getAlbumArtistDetail: ndController.getAlbumArtistDetail,
|
||||
getAlbumArtistList: ndController.getAlbumArtistList,
|
||||
getAlbumDetail: ndController.getAlbumDetail,
|
||||
getAlbumList: ndController.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: ndController.getGenreList,
|
||||
getLyrics: undefined,
|
||||
getMusicFolderList: ssController.getMusicFolderList,
|
||||
getPlaylistDetail: ndController.getPlaylistDetail,
|
||||
getPlaylistList: ndController.getPlaylistList,
|
||||
getPlaylistSongList: ndController.getPlaylistSongList,
|
||||
getRandomSongList: ssController.getRandomSongList,
|
||||
getSongDetail: ndController.getSongDetail,
|
||||
getSongList: ndController.getSongList,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getUserList: ndController.getUserList,
|
||||
removeFromPlaylist: ndController.removeFromPlaylist,
|
||||
scrobble: ssController.scrobble,
|
||||
search: ssController.search3,
|
||||
setRating: ssController.setRating,
|
||||
updatePlaylist: ndController.updatePlaylist,
|
||||
},
|
||||
subsonic: {
|
||||
authenticate: ssController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: ssController.createFavorite,
|
||||
createPlaylist: undefined,
|
||||
deleteFavorite: ssController.removeFavorite,
|
||||
deletePlaylist: undefined,
|
||||
getAlbumArtistDetail: undefined,
|
||||
getAlbumArtistList: undefined,
|
||||
getAlbumDetail: undefined,
|
||||
getAlbumList: undefined,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: undefined,
|
||||
getLyrics: undefined,
|
||||
getMusicFolderList: ssController.getMusicFolderList,
|
||||
getPlaylistDetail: undefined,
|
||||
getPlaylistList: undefined,
|
||||
getSongDetail: undefined,
|
||||
getSongList: undefined,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getUserList: undefined,
|
||||
scrobble: ssController.scrobble,
|
||||
search: ssController.search3,
|
||||
setRating: undefined,
|
||||
updatePlaylist: undefined,
|
||||
},
|
||||
jellyfin: JellyfinController,
|
||||
navidrome: NavidromeController,
|
||||
subsonic: SubsonicController,
|
||||
};
|
||||
|
||||
const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => {
|
||||
@@ -259,6 +96,15 @@ const getAlbumList = async (args: AlbumListArgs) => {
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getAlbumListCount = async (args: AlbumListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumListCount',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumListCount']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
@@ -277,6 +123,15 @@ const getSongList = async (args: SongListArgs) => {
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getSongListCount = async (args: SongListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getSongListCount',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getSongListCount']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getSongDetail = async (args: SongDetailArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
@@ -322,6 +177,15 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getAlbumArtistListCount = async (args: AlbumArtistListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumArtistListCount',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumArtistListCount']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getArtistList = async (args: ArtistListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
@@ -340,6 +204,15 @@ const getPlaylistList = async (args: PlaylistListArgs) => {
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistListCount = async (args: PlaylistListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistListCount',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistListCount']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
@@ -490,18 +363,22 @@ export const controller = {
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumArtistListCount,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getAlbumListCount,
|
||||
getArtistList,
|
||||
getGenreList,
|
||||
getLyrics,
|
||||
getMusicFolderList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistListCount,
|
||||
getPlaylistSongList,
|
||||
getRandomSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getSongListCount,
|
||||
getTopSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
|
||||
@@ -1,62 +1,64 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { z } from 'zod';
|
||||
import packageJson from '../../../../package.json';
|
||||
import { jfNormalize } from './jellyfin-normalize';
|
||||
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import {
|
||||
AuthenticationResponse,
|
||||
MusicFolderListArgs,
|
||||
MusicFolderListResponse,
|
||||
GenreListArgs,
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistListArgs,
|
||||
albumArtistListSortMap,
|
||||
sortOrderMap,
|
||||
ArtistListArgs,
|
||||
artistListSortMap,
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
albumListSortMap,
|
||||
TopSongListArgs,
|
||||
SongListArgs,
|
||||
songListSortMap,
|
||||
AddToPlaylistArgs,
|
||||
RemoveFromPlaylistArgs,
|
||||
PlaylistDetailArgs,
|
||||
PlaylistSongListArgs,
|
||||
PlaylistListArgs,
|
||||
playlistListSortMap,
|
||||
AddToPlaylistResponse,
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistDetailResponse,
|
||||
AlbumArtistListArgs,
|
||||
AlbumArtistListResponse,
|
||||
AlbumDetailArgs,
|
||||
AlbumDetailResponse,
|
||||
AlbumListArgs,
|
||||
AlbumListResponse,
|
||||
AuthenticationResponse,
|
||||
ControllerEndpoint,
|
||||
CreatePlaylistArgs,
|
||||
CreatePlaylistResponse,
|
||||
UpdatePlaylistArgs,
|
||||
UpdatePlaylistResponse,
|
||||
DeletePlaylistArgs,
|
||||
FavoriteArgs,
|
||||
FavoriteResponse,
|
||||
ScrobbleArgs,
|
||||
ScrobbleResponse,
|
||||
GenreListArgs,
|
||||
GenreListResponse,
|
||||
AlbumArtistDetailResponse,
|
||||
AlbumArtistListResponse,
|
||||
AlbumDetailResponse,
|
||||
AlbumListResponse,
|
||||
SongListResponse,
|
||||
AddToPlaylistResponse,
|
||||
RemoveFromPlaylistResponse,
|
||||
PlaylistDetailResponse,
|
||||
PlaylistListResponse,
|
||||
SearchArgs,
|
||||
SearchResponse,
|
||||
RandomSongListResponse,
|
||||
RandomSongListArgs,
|
||||
LyricsArgs,
|
||||
LyricsResponse,
|
||||
genreListSortMap,
|
||||
MusicFolderListArgs,
|
||||
MusicFolderListResponse,
|
||||
PlaylistDetailArgs,
|
||||
PlaylistDetailResponse,
|
||||
PlaylistListArgs,
|
||||
PlaylistListResponse,
|
||||
PlaylistSongListArgs,
|
||||
RandomSongListArgs,
|
||||
RandomSongListResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
RemoveFromPlaylistResponse,
|
||||
ScrobbleArgs,
|
||||
ScrobbleResponse,
|
||||
SearchArgs,
|
||||
SearchResponse,
|
||||
SongDetailArgs,
|
||||
SongDetailResponse,
|
||||
SongListArgs,
|
||||
SongListResponse,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
TopSongListArgs,
|
||||
UpdatePlaylistArgs,
|
||||
UpdatePlaylistResponse,
|
||||
albumArtistListSortMap,
|
||||
albumListSortMap,
|
||||
genreListSortMap,
|
||||
playlistListSortMap,
|
||||
songListSortMap,
|
||||
sortOrderMap,
|
||||
} from '/@/renderer/api/types';
|
||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||
import { jfNormalize } from './jellyfin-normalize';
|
||||
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import packageJson from '../../../../package.json';
|
||||
import { z } from 'zod';
|
||||
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
||||
import isElectron from 'is-electron';
|
||||
import { sortSongList } from '/@/renderer/api/utils';
|
||||
|
||||
const formatCommaDelimitedString = (value: string[]) => {
|
||||
return value.join(',');
|
||||
@@ -244,31 +246,56 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtis
|
||||
};
|
||||
};
|
||||
|
||||
const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListResponse> => {
|
||||
const getAlbumArtistListCount = async (args: AlbumArtistListArgs): Promise<number> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
Limit: query.limit,
|
||||
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
|
||||
ImageTypeLimit: 1,
|
||||
Limit: 1,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
StartIndex: 0,
|
||||
UserId: apiClientProps.server?.userId || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get artist list');
|
||||
throw new Error('Failed to get album artist list count');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
return res.body.TotalRecordCount;
|
||||
};
|
||||
|
||||
// const getArtistList = async (args: ArtistListArgs): Promise<ArtistListResponse> => {
|
||||
// const { query, apiClientProps } = args;
|
||||
|
||||
// const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
||||
// query: {
|
||||
// Limit: query.limit,
|
||||
// ParentId: query.musicFolderId,
|
||||
// Recursive: true,
|
||||
// SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
|
||||
// SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
// StartIndex: query.startIndex,
|
||||
// },
|
||||
// });
|
||||
|
||||
// if (res.status !== 200) {
|
||||
// throw new Error('Failed to get artist list');
|
||||
// }
|
||||
|
||||
// return {
|
||||
// items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
|
||||
// startIndex: query.startIndex,
|
||||
// totalRecordCount: res.body.TotalRecordCount,
|
||||
// };
|
||||
// };
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
@@ -333,6 +360,7 @@ const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> =>
|
||||
AlbumArtistIds: query.artistIds
|
||||
? formatCommaDelimitedString(query.artistIds)
|
||||
: undefined,
|
||||
ContributingArtistIds: query.isCompilation ? query.artistIds?.[0] : undefined,
|
||||
IncludeItemTypes: 'MusicAlbum',
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
@@ -357,6 +385,55 @@ const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> =>
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumListCount = async (args: AlbumListArgs): Promise<number> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const yearsGroup = [];
|
||||
if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) {
|
||||
for (
|
||||
let i = Number(query._custom?.jellyfin?.minYear);
|
||||
i <= Number(query._custom?.jellyfin?.maxYear);
|
||||
i += 1
|
||||
) {
|
||||
yearsGroup.push(String(i));
|
||||
}
|
||||
}
|
||||
|
||||
const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getAlbumList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
AlbumArtistIds: query.artistIds
|
||||
? formatCommaDelimitedString(query.artistIds)
|
||||
: undefined,
|
||||
ContributingArtistIds: query.isCompilation ? query.artistIds?.[0] : undefined,
|
||||
IncludeItemTypes: 'MusicAlbum',
|
||||
Limit: 1,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: 0,
|
||||
...query._custom?.jellyfin,
|
||||
Years: yearsFilter,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album list count');
|
||||
}
|
||||
|
||||
return res.body.TotalRecordCount;
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -384,8 +461,11 @@ const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse>
|
||||
throw new Error('Failed to get top song list');
|
||||
}
|
||||
|
||||
const songs = res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, ''));
|
||||
const songsByPlayCount = sortSongList(songs, SongListSort.PLAY_COUNT, SortOrder.DESC);
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||
items: songsByPlayCount,
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
@@ -449,6 +529,58 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||
};
|
||||
};
|
||||
|
||||
const getSongListCount = async (args: SongListArgs): Promise<number> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const yearsGroup = [];
|
||||
if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) {
|
||||
for (
|
||||
let i = Number(query._custom?.jellyfin?.minYear);
|
||||
i <= Number(query._custom?.jellyfin?.maxYear);
|
||||
i += 1
|
||||
) {
|
||||
yearsGroup.push(String(i));
|
||||
}
|
||||
}
|
||||
|
||||
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
|
||||
const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined;
|
||||
const artistIdsFilter = query.artistIds
|
||||
? formatCommaDelimitedString(query.artistIds)
|
||||
: undefined;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getSongList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
AlbumIds: albumIdsFilter,
|
||||
ArtistIds: artistIdsFilter,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
IncludeItemTypes: 'Audio',
|
||||
Limit: 1,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: 0,
|
||||
...query._custom?.jellyfin,
|
||||
Years: yearsFilter,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list count');
|
||||
}
|
||||
|
||||
return res.body.TotalRecordCount;
|
||||
};
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => {
|
||||
const { query, body, apiClientProps } = args;
|
||||
|
||||
@@ -535,7 +667,6 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<SongList
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||
IncludeItemTypes: 'Audio',
|
||||
Limit: query.limit,
|
||||
SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
|
||||
SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
|
||||
StartIndex: 0,
|
||||
@@ -549,7 +680,7 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<SongList
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||
startIndex: query.startIndex,
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
};
|
||||
@@ -589,6 +720,37 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
|
||||
};
|
||||
};
|
||||
|
||||
const getPlaylistListCount = async (args: PlaylistListArgs): Promise<number> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getPlaylistList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
||||
IncludeItemTypes: 'Playlist',
|
||||
Limit: 1,
|
||||
MediaTypes: 'Audio',
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: playlistListSortMap.jellyfin[query.sortBy],
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist list count');
|
||||
}
|
||||
|
||||
return res.body.TotalRecordCount;
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
|
||||
const { body, apiClientProps } = args;
|
||||
|
||||
@@ -946,7 +1108,7 @@ const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse>
|
||||
return jfNormalize.song(res.body, apiClientProps.server, '');
|
||||
};
|
||||
|
||||
export const jfController = {
|
||||
export const JellyfinController: ControllerEndpoint = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createFavorite,
|
||||
@@ -955,19 +1117,22 @@ export const jfController = {
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumArtistListCount,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getArtistList,
|
||||
getAlbumListCount,
|
||||
getGenreList,
|
||||
getLyrics,
|
||||
getMusicFolderList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistListCount,
|
||||
getPlaylistSongList,
|
||||
getRandomSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getTopSongList,
|
||||
getSongListCount,
|
||||
getTopSongs: getTopSongList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
search,
|
||||
|
||||
@@ -39,11 +39,13 @@ import {
|
||||
RemoveFromPlaylistResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
genreListSortMap,
|
||||
ControllerEndpoint,
|
||||
} from '../types';
|
||||
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
|
||||
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
|
||||
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
@@ -129,7 +131,7 @@ const getAlbumArtistDetail = async (
|
||||
},
|
||||
});
|
||||
|
||||
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
|
||||
const artistInfoRes = await subsonicApiClient(apiClientProps).getArtistInfo({
|
||||
query: {
|
||||
count: 10,
|
||||
id: query.id,
|
||||
@@ -148,15 +150,16 @@ const getAlbumArtistDetail = async (
|
||||
{
|
||||
...res.body.data,
|
||||
...(artistInfoRes.status === 200 && {
|
||||
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
|
||||
similarArtists: artistInfoRes.body['subsonic-response'].artistInfo.similarArtist,
|
||||
...(!res.body.data.largeImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl,
|
||||
largeImageUrl: artistInfoRes.body['subsonic-response'].artistInfo.largeImageUrl,
|
||||
}),
|
||||
...(!res.body.data.mediumImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl,
|
||||
largeImageUrl:
|
||||
artistInfoRes.body['subsonic-response'].artistInfo.mediumImageUrl,
|
||||
}),
|
||||
...(!res.body.data.smallImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl,
|
||||
largeImageUrl: artistInfoRes.body['subsonic-response'].artistInfo.smallImageUrl,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
@@ -191,6 +194,27 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtis
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumArtistListCount = async (args: AlbumArtistListArgs): Promise<number> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
_end: 1,
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumArtistListSortMap.navidrome[query.sortBy],
|
||||
_start: 0,
|
||||
name: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album artist list count');
|
||||
}
|
||||
|
||||
return Number(res.body.headers.get('x-total-count') || 0);
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
@@ -230,6 +254,8 @@ const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> =>
|
||||
_sort: albumListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
artist_id: query.artistIds?.[0],
|
||||
compilation: query.isCompilation,
|
||||
genre_id: query.genre,
|
||||
name: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
@@ -246,6 +272,30 @@ const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> =>
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumListCount = async (args: AlbumListArgs): Promise<number> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getAlbumList({
|
||||
query: {
|
||||
_end: 1,
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumListSortMap.navidrome[query.sortBy],
|
||||
_start: 0,
|
||||
artist_id: query.artistIds?.[0],
|
||||
compilation: query.isCompilation,
|
||||
genre_id: query.genre,
|
||||
name: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album list');
|
||||
}
|
||||
|
||||
return Number(res.body.headers.get('x-total-count') || 0);
|
||||
};
|
||||
|
||||
const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
@@ -275,6 +325,29 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||
};
|
||||
};
|
||||
|
||||
const getSongListCount = async (args: SongListArgs): Promise<number> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getSongList({
|
||||
query: {
|
||||
_end: 1,
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: songListSortMap.navidrome[query.sortBy],
|
||||
_start: 0,
|
||||
album_artist_id: query.artistIds,
|
||||
album_id: query.albumIds,
|
||||
title: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list count');
|
||||
}
|
||||
|
||||
return Number(res.body.headers.get('x-total-count') || 0);
|
||||
};
|
||||
|
||||
const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
@@ -298,7 +371,7 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
|
||||
body: {
|
||||
comment: body.comment,
|
||||
name: body.name,
|
||||
public: body._custom?.navidrome?.public,
|
||||
public: body.public,
|
||||
rules: body._custom?.navidrome?.rules,
|
||||
sync: body._custom?.navidrome?.sync,
|
||||
},
|
||||
@@ -322,7 +395,7 @@ const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistR
|
||||
name: body.name,
|
||||
public: body._custom?.navidrome?.public || false,
|
||||
rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined,
|
||||
sync: body._custom?.navidrome?.sync || undefined,
|
||||
sync: body._custom?.navidrome?.sync,
|
||||
},
|
||||
params: {
|
||||
id: query.id,
|
||||
@@ -360,7 +433,9 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
|
||||
_sort: query.sortBy
|
||||
? playlistListSortMap.navidrome[query.sortBy]
|
||||
: playlistListSortMap.navidrome.name,
|
||||
_start: query.startIndex,
|
||||
q: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
@@ -378,6 +453,29 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
|
||||
};
|
||||
};
|
||||
|
||||
const getPlaylistListCount = async (args: PlaylistListArgs): Promise<number> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistList({
|
||||
query: {
|
||||
_end: 1,
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: query.sortBy
|
||||
? playlistListSortMap.navidrome[query.sortBy]
|
||||
: playlistListSortMap.navidrome.name,
|
||||
_start: 0,
|
||||
q: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist list count');
|
||||
}
|
||||
|
||||
return Number(res.body.headers.get('x-total-count') || 0);
|
||||
};
|
||||
|
||||
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<PlaylistDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
@@ -404,12 +502,11 @@ const getPlaylistSongList = async (
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
|
||||
_sort: query.sortBy
|
||||
? songListSortMap.navidrome[query.sortBy]
|
||||
: ndType._enum.songList.ID,
|
||||
_start: query.startIndex,
|
||||
_start: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -419,7 +516,7 @@ const getPlaylistSongList = async (
|
||||
|
||||
return {
|
||||
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')),
|
||||
startIndex: query?.startIndex || 0,
|
||||
startIndex: 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
@@ -465,22 +562,41 @@ const removeFromPlaylist = async (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ndController = {
|
||||
export const NavidromeController: ControllerEndpoint = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: SubsonicController.createFavorite,
|
||||
createPlaylist,
|
||||
deleteFavorite: SubsonicController.deleteFavorite,
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumArtistListCount,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getAlbumListCount,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList,
|
||||
getMusicFolderList: SubsonicController.getMusicFolderList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistListCount,
|
||||
getPlaylistSongList,
|
||||
getRandomSongList: SubsonicController.getRandomSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getSongListCount,
|
||||
getTopSongs: SubsonicController.getTopSongs,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
scrobble: SubsonicController.scrobble,
|
||||
search: SubsonicController.search,
|
||||
setRating: SubsonicController.setRating,
|
||||
updatePlaylist,
|
||||
};
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
import z from 'zod';
|
||||
import { ndType } from './navidrome-types';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { NDGenre } from '/@/renderer/api/navidrome.types';
|
||||
import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
|
||||
const getImageUrl = (args: { url: string | null }) => {
|
||||
const { url } = args;
|
||||
@@ -186,7 +186,9 @@ const normalizeAlbum = (
|
||||
|
||||
const normalizeAlbumArtist = (
|
||||
item: z.infer<typeof ndType._response.albumArtist> & {
|
||||
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
|
||||
similarArtists?: z.infer<
|
||||
typeof SubsonicApi.getArtistInfo2.response
|
||||
>['subsonic-response']['artistInfo2']['similarArtist'];
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
): AlbumArtist => {
|
||||
|
||||
@@ -49,6 +49,19 @@ export const queryKeys: Record<
|
||||
Record<string, (...props: any) => QueryFunctionContext['queryKey']>
|
||||
> = {
|
||||
albumArtists: {
|
||||
count: (serverId: string, query?: AlbumArtistListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
|
||||
if (query && pagination) {
|
||||
return [serverId, 'albumArtists', 'count', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'albumArtists', 'count', filter] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'albumArtists', 'count'] as const;
|
||||
},
|
||||
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
|
||||
return [serverId, 'albumArtists', 'detail'] as const;
|
||||
@@ -72,23 +85,40 @@ export const queryKeys: Record<
|
||||
},
|
||||
},
|
||||
albums: {
|
||||
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
||||
[serverId, 'albums', 'detail', query] as const,
|
||||
list: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
|
||||
count: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
|
||||
if (query && pagination && artistId) {
|
||||
return [serverId, 'albums', 'list', artistId, filter, pagination] as const;
|
||||
return [serverId, 'albums', 'count', artistId, filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query && pagination) {
|
||||
return [serverId, 'albums', 'list', filter, pagination] as const;
|
||||
return [serverId, 'albums', 'count', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query && artistId) {
|
||||
return [serverId, 'albums', 'list', artistId, filter] as const;
|
||||
return [serverId, 'albums', 'count', artistId, filter] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'albums', 'count', filter] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'albums', 'count'] as const;
|
||||
},
|
||||
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
||||
[serverId, 'albums', 'detail', query] as const,
|
||||
list: (
|
||||
serverId: string,
|
||||
query?: {
|
||||
artistIds?: string[];
|
||||
maxYear?: number;
|
||||
minYear?: number;
|
||||
searchTerm?: string;
|
||||
},
|
||||
) => {
|
||||
const { filter } = splitPaginatedQuery(query);
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'albums', 'list', filter] as const;
|
||||
}
|
||||
@@ -207,6 +237,19 @@ export const queryKeys: Record<
|
||||
root: (serverId: string) => [serverId] as const,
|
||||
},
|
||||
songs: {
|
||||
count: (serverId: string, query?: AlbumArtistListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
|
||||
if (query && pagination) {
|
||||
return [serverId, 'songs', 'count', filter, pagination] as const;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return [serverId, 'songs', 'count', filter] as const;
|
||||
}
|
||||
|
||||
return [serverId, 'songs', 'count'] as const;
|
||||
},
|
||||
detail: (serverId: string, query?: SongDetailQuery) => {
|
||||
if (query) return [serverId, 'songs', 'detail', query] as const;
|
||||
return [serverId, 'songs', 'detail'] as const;
|
||||
|
||||
@@ -1,93 +1,426 @@
|
||||
import { initClient, initContract } from '@ts-rest/core';
|
||||
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios';
|
||||
import axios, { AxiosError, AxiosResponse, Method, isAxiosError } from 'axios';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
import qs from 'qs';
|
||||
import { z } from 'zod';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { ServerListItem } from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const contract = c.router({
|
||||
authenticate: {
|
||||
changePassword: {
|
||||
method: 'GET',
|
||||
path: 'ping.view',
|
||||
query: ssType._parameters.authenticate,
|
||||
path: 'changePassword.view',
|
||||
query: SubsonicApi.changePassword.parameters,
|
||||
responses: {
|
||||
200: ssType._response.authenticate,
|
||||
200: SubsonicApi.changePassword.response,
|
||||
},
|
||||
},
|
||||
createFavorite: {
|
||||
createInternetRadioStation: {
|
||||
method: 'GET',
|
||||
path: 'star.view',
|
||||
query: ssType._parameters.createFavorite,
|
||||
path: 'createInternetRadioStation.view',
|
||||
query: SubsonicApi.createInternetRadioStation.parameters,
|
||||
responses: {
|
||||
200: ssType._response.createFavorite,
|
||||
200: SubsonicApi.createInternetRadioStation.response,
|
||||
},
|
||||
},
|
||||
createPlaylist: {
|
||||
method: 'GET',
|
||||
path: 'createPlaylist.view',
|
||||
query: SubsonicApi.createPlaylist.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.createPlaylist.response,
|
||||
},
|
||||
},
|
||||
createShare: {
|
||||
method: 'GET',
|
||||
path: 'createShare.view',
|
||||
query: SubsonicApi.createShare.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.createShare.response,
|
||||
},
|
||||
},
|
||||
createUser: {
|
||||
method: 'GET',
|
||||
path: 'createUser.view',
|
||||
query: SubsonicApi.createUser.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.createUser.response,
|
||||
},
|
||||
},
|
||||
deleteInternetRadioStation: {
|
||||
method: 'GET',
|
||||
path: 'deleteInternetRadioStation.view',
|
||||
query: SubsonicApi.deleteInternetRadioStation.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.deleteInternetRadioStation.response,
|
||||
},
|
||||
},
|
||||
deletePlaylist: {
|
||||
method: 'GET',
|
||||
path: 'deletePlaylist.view',
|
||||
query: SubsonicApi.deletePlaylist.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.deletePlaylist.response,
|
||||
},
|
||||
},
|
||||
deleteShare: {
|
||||
method: 'GET',
|
||||
path: 'deleteShare.view',
|
||||
query: SubsonicApi.deleteShare.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.deleteShare.response,
|
||||
},
|
||||
},
|
||||
deleteUser: {
|
||||
method: 'GET',
|
||||
path: 'deleteUser.view',
|
||||
query: SubsonicApi.deleteUser.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.deleteUser.response,
|
||||
},
|
||||
},
|
||||
getAlbum: {
|
||||
method: 'GET',
|
||||
path: 'getAlbum.view',
|
||||
query: SubsonicApi.getAlbum.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getAlbum.response,
|
||||
},
|
||||
},
|
||||
getAlbumInfo: {
|
||||
method: 'GET',
|
||||
path: 'getAlbumInfo.view',
|
||||
query: SubsonicApi.getAlbumInfo.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getAlbumInfo.response,
|
||||
},
|
||||
},
|
||||
getAlbumInfo2: {
|
||||
method: 'GET',
|
||||
path: 'getAlbumInfo2.view',
|
||||
query: SubsonicApi.getAlbumInfo2.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getAlbumInfo2.response,
|
||||
},
|
||||
},
|
||||
getAlbumList: {
|
||||
method: 'GET',
|
||||
path: 'getAlbumList.view',
|
||||
query: SubsonicApi.getAlbumList.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getAlbumList.response,
|
||||
},
|
||||
},
|
||||
getAlbumList2: {
|
||||
method: 'GET',
|
||||
path: 'getAlbumList2.view',
|
||||
query: SubsonicApi.getAlbumList2.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getAlbumList2.response,
|
||||
},
|
||||
},
|
||||
getArtist: {
|
||||
method: 'GET',
|
||||
path: 'getArtist.view',
|
||||
query: SubsonicApi.getArtist.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getArtist.response,
|
||||
},
|
||||
},
|
||||
getArtistInfo: {
|
||||
method: 'GET',
|
||||
path: 'getArtistInfo.view',
|
||||
query: ssType._parameters.artistInfo,
|
||||
query: SubsonicApi.getArtistInfo.parameters,
|
||||
responses: {
|
||||
200: ssType._response.artistInfo,
|
||||
200: SubsonicApi.getArtistInfo.response,
|
||||
},
|
||||
},
|
||||
getMusicFolderList: {
|
||||
getArtistInfo2: {
|
||||
method: 'GET',
|
||||
path: 'getArtistInfo2.view',
|
||||
query: SubsonicApi.getArtistInfo2.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getArtistInfo2.response,
|
||||
},
|
||||
},
|
||||
getArtists: {
|
||||
method: 'GET',
|
||||
path: 'getArtists.view',
|
||||
query: SubsonicApi.getArtists.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getArtists.response,
|
||||
},
|
||||
},
|
||||
getGenres: {
|
||||
method: 'GET',
|
||||
path: 'getGenres.view',
|
||||
query: SubsonicApi.getGenres.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getGenres.response,
|
||||
},
|
||||
},
|
||||
getIndexes: {
|
||||
method: 'GET',
|
||||
path: 'getIndexes.view',
|
||||
query: SubsonicApi.getIndexes.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getIndexes.response,
|
||||
},
|
||||
},
|
||||
getInternetRadioStations: {
|
||||
method: 'GET',
|
||||
path: 'getInternetRadioStations.view',
|
||||
query: SubsonicApi.getInternetRadioStations.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getInternetRadioStations.response,
|
||||
},
|
||||
},
|
||||
getLicense: {
|
||||
method: 'GET',
|
||||
path: 'getLicense.view',
|
||||
query: SubsonicApi.getLicense.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getLicense.response,
|
||||
},
|
||||
},
|
||||
getLyrics: {
|
||||
method: 'GET',
|
||||
path: 'getLyrics.view',
|
||||
query: SubsonicApi.getLyrics.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getLyrics.response,
|
||||
},
|
||||
},
|
||||
getMusicDirectory: {
|
||||
method: 'GET',
|
||||
path: 'getMusicDirectory.view',
|
||||
query: SubsonicApi.getMusicDirectory.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getMusicDirectory.response,
|
||||
},
|
||||
},
|
||||
getMusicFolders: {
|
||||
method: 'GET',
|
||||
path: 'getMusicFolders.view',
|
||||
responses: {
|
||||
200: ssType._response.musicFolderList,
|
||||
200: SubsonicApi.getMusicFolders.response,
|
||||
},
|
||||
},
|
||||
getRandomSongList: {
|
||||
getNowPlaying: {
|
||||
method: 'GET',
|
||||
path: 'getNowPlaying.view',
|
||||
query: SubsonicApi.getNowPlaying.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getNowPlaying.response,
|
||||
},
|
||||
},
|
||||
getOpenSubsonicExtensions: {
|
||||
method: 'GET',
|
||||
path: 'getOpenSubsonicExtensions.view',
|
||||
query: SubsonicApi.getOpenSubsonicExtensions.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getOpenSubsonicExtensions.response,
|
||||
},
|
||||
},
|
||||
getPlaylist: {
|
||||
method: 'GET',
|
||||
path: 'getPlaylist.view',
|
||||
query: SubsonicApi.getPlaylist.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getPlaylist.response,
|
||||
},
|
||||
},
|
||||
getPlaylists: {
|
||||
method: 'GET',
|
||||
path: 'getPlaylists.view',
|
||||
query: SubsonicApi.getPlaylists.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getPlaylists.response,
|
||||
},
|
||||
},
|
||||
getRandomSongs: {
|
||||
method: 'GET',
|
||||
path: 'getRandomSongs.view',
|
||||
query: ssType._parameters.randomSongList,
|
||||
query: SubsonicApi.getRandomSongs.parameters,
|
||||
responses: {
|
||||
200: ssType._response.randomSongList,
|
||||
200: SubsonicApi.getRandomSongs.response,
|
||||
},
|
||||
},
|
||||
getTopSongsList: {
|
||||
getScanStatus: {
|
||||
method: 'GET',
|
||||
path: 'getScanStatus.view',
|
||||
responses: {
|
||||
200: SubsonicApi.getScanStatus.response,
|
||||
},
|
||||
},
|
||||
getShares: {
|
||||
method: 'GET',
|
||||
path: 'getShares.view',
|
||||
query: SubsonicApi.getShares.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getShares.response,
|
||||
},
|
||||
},
|
||||
getSimilarSongs: {
|
||||
method: 'GET',
|
||||
path: 'getSimilarSongs.view',
|
||||
query: SubsonicApi.getSimilarSongs.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getSimilarSongs.response,
|
||||
},
|
||||
},
|
||||
getSimilarSongs2: {
|
||||
method: 'GET',
|
||||
path: 'getSimilarSongs2.view',
|
||||
query: SubsonicApi.getSimilarSongs2.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getSimilarSongs2.response,
|
||||
},
|
||||
},
|
||||
getSong: {
|
||||
method: 'GET',
|
||||
path: 'getSong.view',
|
||||
query: SubsonicApi.getSong.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getSong.response,
|
||||
},
|
||||
},
|
||||
getSongsByGenre: {
|
||||
method: 'GET',
|
||||
path: 'getSongsByGenre.view',
|
||||
query: SubsonicApi.getSongsByGenre.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getSongsByGenre.response,
|
||||
},
|
||||
},
|
||||
getStarred: {
|
||||
method: 'GET',
|
||||
path: 'getStarred.view',
|
||||
query: SubsonicApi.getStarred.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getStarred.response,
|
||||
},
|
||||
},
|
||||
getStarred2: {
|
||||
method: 'GET',
|
||||
path: 'getStarred2.view',
|
||||
query: SubsonicApi.getStarred2.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getStarred2.response,
|
||||
},
|
||||
},
|
||||
getTopSongs: {
|
||||
method: 'GET',
|
||||
path: 'getTopSongs.view',
|
||||
query: ssType._parameters.topSongsList,
|
||||
query: SubsonicApi.getTopSongs.parameters,
|
||||
responses: {
|
||||
200: ssType._response.topSongsList,
|
||||
200: SubsonicApi.getTopSongs.response,
|
||||
},
|
||||
},
|
||||
removeFavorite: {
|
||||
getUser: {
|
||||
method: 'GET',
|
||||
path: 'unstar.view',
|
||||
query: ssType._parameters.removeFavorite,
|
||||
path: 'getUser.view',
|
||||
query: SubsonicApi.getUser.parameters,
|
||||
responses: {
|
||||
200: ssType._response.removeFavorite,
|
||||
200: SubsonicApi.getUser.response,
|
||||
},
|
||||
},
|
||||
getUsers: {
|
||||
method: 'GET',
|
||||
path: 'getUsers.view',
|
||||
query: SubsonicApi.getUsers.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.getUsers.response,
|
||||
},
|
||||
},
|
||||
ping: {
|
||||
method: 'GET',
|
||||
path: 'ping.view',
|
||||
query: SubsonicApi.ping.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.ping.response,
|
||||
},
|
||||
},
|
||||
scrobble: {
|
||||
method: 'GET',
|
||||
path: 'scrobble.view',
|
||||
query: ssType._parameters.scrobble,
|
||||
query: SubsonicApi.scrobble.parameters,
|
||||
responses: {
|
||||
200: ssType._response.scrobble,
|
||||
200: SubsonicApi.scrobble.response,
|
||||
},
|
||||
},
|
||||
search3: {
|
||||
method: 'GET',
|
||||
path: 'search3.view',
|
||||
query: ssType._parameters.search3,
|
||||
query: SubsonicApi.search3.parameters,
|
||||
responses: {
|
||||
200: ssType._response.search3,
|
||||
200: SubsonicApi.search3.response,
|
||||
},
|
||||
},
|
||||
setRating: {
|
||||
method: 'GET',
|
||||
path: 'setRating.view',
|
||||
query: ssType._parameters.setRating,
|
||||
query: SubsonicApi.setRating.parameters,
|
||||
responses: {
|
||||
200: ssType._response.setRating,
|
||||
200: SubsonicApi.setRating.response,
|
||||
},
|
||||
},
|
||||
star: {
|
||||
method: 'GET',
|
||||
path: 'star.view',
|
||||
query: SubsonicApi.star.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.star.response,
|
||||
},
|
||||
},
|
||||
startScan: {
|
||||
method: 'GET',
|
||||
path: 'startScan.view',
|
||||
responses: {
|
||||
200: SubsonicApi.startScan.response,
|
||||
},
|
||||
},
|
||||
unstar: {
|
||||
method: 'GET',
|
||||
path: 'unstar.view',
|
||||
query: SubsonicApi.unstar.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.unstar.response,
|
||||
},
|
||||
},
|
||||
updateInternetRadioStation: {
|
||||
method: 'GET',
|
||||
path: 'updateInternetRadioStation.view',
|
||||
query: SubsonicApi.updateInternetRadioStation.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.updateInternetRadioStation.response,
|
||||
},
|
||||
},
|
||||
updatePlaylist: {
|
||||
method: 'GET',
|
||||
path: 'updatePlaylist.view',
|
||||
query: SubsonicApi.updatePlaylist.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.updatePlaylist.response,
|
||||
},
|
||||
},
|
||||
updateShare: {
|
||||
method: 'GET',
|
||||
path: 'updateShare.view',
|
||||
query: SubsonicApi.updateShare.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.updateShare.response,
|
||||
},
|
||||
},
|
||||
updateUser: {
|
||||
method: 'GET',
|
||||
path: 'updateUser.view',
|
||||
query: SubsonicApi.updateUser.parameters,
|
||||
responses: {
|
||||
200: SubsonicApi.updateUser.response,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -102,14 +435,21 @@ axiosClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const data = response.data;
|
||||
|
||||
if (data['subsonic-response'].status !== 'ok') {
|
||||
// Ping endpoint returns a string
|
||||
if (typeof data === 'string') {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (data['subsonic-response']?.status !== 'ok') {
|
||||
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
||||
if (data['subsonic-response'].error.code !== 0) {
|
||||
if (data['subsonic-response']?.error.code !== 0) {
|
||||
toast.error({
|
||||
message: data['subsonic-response'].error.message,
|
||||
message: data['subsonic-response']?.error.message,
|
||||
title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(data['subsonic-response']?.error);
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -131,7 +471,7 @@ const parsePath = (fullPath: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const ssApiClient = (args: {
|
||||
export const subsonicApiClient = (args: {
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
url?: string;
|
||||
@@ -162,9 +502,7 @@ export const ssApiClient = (args: {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await axiosClient.request<
|
||||
z.infer<typeof ssType._response.baseResponse>
|
||||
>({
|
||||
const result = await axiosClient.request({
|
||||
data: body,
|
||||
headers,
|
||||
method: method as Method,
|
||||
@@ -180,9 +518,9 @@ export const ssApiClient = (args: {
|
||||
});
|
||||
|
||||
return {
|
||||
body: result.data['subsonic-response'],
|
||||
headers: result.headers as any,
|
||||
status: result.status,
|
||||
body: result?.data,
|
||||
headers: result?.headers as any,
|
||||
status: result?.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
if (isAxiosError(e)) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,15 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/types';
|
||||
import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import {
|
||||
QueueSong,
|
||||
LibraryItem,
|
||||
AlbumArtist,
|
||||
Album,
|
||||
Genre,
|
||||
MusicFolder,
|
||||
Playlist,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
@@ -27,16 +35,17 @@ const getCoverArtUrl = (args: {
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof ssType._response.song>,
|
||||
item: z.infer<typeof SubsonicApi._baseTypes.song>,
|
||||
server: ServerListItem | null,
|
||||
deviceId: string,
|
||||
size?: number,
|
||||
): QueueSong => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 100,
|
||||
size: size || 300,
|
||||
}) || null;
|
||||
|
||||
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
|
||||
@@ -105,15 +114,18 @@ const normalizeSong = (
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (
|
||||
item: z.infer<typeof ssType._response.albumArtist>,
|
||||
item:
|
||||
| z.infer<typeof SubsonicApi._baseTypes.artist>
|
||||
| z.infer<typeof SubsonicApi._baseTypes.artistListEntry>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): AlbumArtist => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 100,
|
||||
size: imageSize || 100,
|
||||
}) || null;
|
||||
|
||||
return {
|
||||
@@ -138,15 +150,18 @@ const normalizeAlbumArtist = (
|
||||
};
|
||||
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof ssType._response.album>,
|
||||
item:
|
||||
| z.infer<typeof SubsonicApi._baseTypes.album>
|
||||
| z.infer<typeof SubsonicApi._baseTypes.albumListEntry>,
|
||||
server: ServerListItem | null,
|
||||
size?: number,
|
||||
): Album => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 300,
|
||||
size: size || 300,
|
||||
}) || null;
|
||||
|
||||
return {
|
||||
@@ -156,7 +171,7 @@ const normalizeAlbum = (
|
||||
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
|
||||
backdropImageUrl: null,
|
||||
createdAt: item.created,
|
||||
duration: item.duration,
|
||||
duration: item.duration * 1000,
|
||||
genres: item.genre
|
||||
? [
|
||||
{
|
||||
@@ -181,7 +196,10 @@ const normalizeAlbum = (
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: null,
|
||||
songCount: item.songCount,
|
||||
songs: [],
|
||||
songs:
|
||||
(item as z.infer<typeof SubsonicApi._baseTypes.album>).song?.map((song) =>
|
||||
normalizeSong(song, server, ''),
|
||||
) || [],
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.created,
|
||||
userFavorite: item.starred || false,
|
||||
@@ -189,8 +207,61 @@ const normalizeAlbum = (
|
||||
};
|
||||
};
|
||||
|
||||
export const ssNormalize = {
|
||||
const normalizeGenre = (item: z.infer<typeof SubsonicApi._baseTypes.genre>): Genre => {
|
||||
return {
|
||||
albumCount: item.albumCount,
|
||||
id: item.value,
|
||||
imageUrl: null,
|
||||
itemType: LibraryItem.GENRE,
|
||||
name: item.value,
|
||||
songCount: item.songCount,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeMusicFolder = (
|
||||
item: z.infer<typeof SubsonicApi._baseTypes.musicFolder>,
|
||||
): MusicFolder => {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item:
|
||||
| z.infer<typeof SubsonicApi._baseTypes.playlist>
|
||||
| z.infer<typeof SubsonicApi._baseTypes.playlistListEntry>,
|
||||
server: ServerListItem | null,
|
||||
): Playlist => {
|
||||
return {
|
||||
description: item.comment || null,
|
||||
duration: item.duration,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl: getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 300,
|
||||
}),
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.name,
|
||||
owner: item.owner,
|
||||
ownerId: item.owner,
|
||||
public: item.public,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: null,
|
||||
songCount: item.songCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const subsonicNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
genre: normalizeGenre,
|
||||
musicFolder: normalizeMusicFolder,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -124,7 +124,7 @@ export interface BasePaginatedResponse<T> {
|
||||
error?: string | any;
|
||||
items: T;
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
totalRecordCount: number | null;
|
||||
}
|
||||
|
||||
export type AuthenticationResponse = {
|
||||
@@ -306,7 +306,9 @@ export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefine
|
||||
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
|
||||
|
||||
export enum GenreListSort {
|
||||
ALBUM_COUNT = 'albumCount',
|
||||
NAME = 'name',
|
||||
SONG_COUNT = 'songCount',
|
||||
}
|
||||
|
||||
export type GenreListQuery = {
|
||||
@@ -330,10 +332,14 @@ type GenreListSortMap = {
|
||||
|
||||
export const genreListSortMap: GenreListSortMap = {
|
||||
jellyfin: {
|
||||
albumCount: undefined,
|
||||
name: JFGenreListSort.NAME,
|
||||
songCount: undefined,
|
||||
},
|
||||
navidrome: {
|
||||
albumCount: undefined,
|
||||
name: NDGenreListSort.NAME,
|
||||
songCount: undefined,
|
||||
},
|
||||
subsonic: {
|
||||
name: undefined,
|
||||
@@ -370,7 +376,12 @@ export type AlbumListQuery = {
|
||||
navidrome?: Partial<z.infer<typeof ndType._parameters.albumList>>;
|
||||
};
|
||||
artistIds?: string[];
|
||||
genre?: string;
|
||||
isCompilation?: boolean;
|
||||
isFavorite?: boolean;
|
||||
limit?: number;
|
||||
maxYear?: number;
|
||||
minYear?: number;
|
||||
musicFolderId?: string;
|
||||
searchTerm?: string;
|
||||
sortBy: AlbumListSort;
|
||||
@@ -481,8 +492,13 @@ export type SongListQuery = {
|
||||
};
|
||||
albumIds?: string[];
|
||||
artistIds?: string[];
|
||||
genre?: string;
|
||||
genreId?: string;
|
||||
imageSize?: number;
|
||||
isFavorite?: boolean;
|
||||
limit?: number;
|
||||
maxYear?: number;
|
||||
minYear?: number;
|
||||
musicFolderId?: string;
|
||||
searchTerm?: string;
|
||||
sortBy: SongListSort;
|
||||
@@ -802,6 +818,7 @@ export type CreatePlaylistBody = {
|
||||
};
|
||||
comment?: string;
|
||||
name: string;
|
||||
public?: boolean;
|
||||
};
|
||||
|
||||
export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs;
|
||||
@@ -826,6 +843,11 @@ export type UpdatePlaylistBody = {
|
||||
comment?: string;
|
||||
genres?: Genre[];
|
||||
name: string;
|
||||
owner?: string;
|
||||
ownerId?: string;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any>;
|
||||
sync?: boolean;
|
||||
};
|
||||
|
||||
export type UpdatePlaylistArgs = {
|
||||
@@ -917,10 +939,9 @@ export type PlaylistSongListResponse = BasePaginatedResponse<Song[]> | null | un
|
||||
|
||||
export type PlaylistSongListQuery = {
|
||||
id: string;
|
||||
limit?: number;
|
||||
sortBy?: SongListSort;
|
||||
sortOrder?: SortOrder;
|
||||
startIndex: number;
|
||||
searchTerm?: string;
|
||||
sortBy: SongListSort;
|
||||
sortOrder: SortOrder;
|
||||
};
|
||||
|
||||
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
|
||||
@@ -1014,7 +1035,7 @@ export type SearchQuery = {
|
||||
albumLimit?: number;
|
||||
albumStartIndex?: number;
|
||||
musicFolderId?: string;
|
||||
query?: string;
|
||||
query: string;
|
||||
songLimit?: number;
|
||||
songStartIndex?: number;
|
||||
};
|
||||
@@ -1139,3 +1160,48 @@ export type FontData = {
|
||||
postscriptName: string;
|
||||
style: string;
|
||||
};
|
||||
|
||||
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>;
|
||||
getAlbumArtistListCount: (args: AlbumArtistListArgs) => Promise<number>;
|
||||
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
|
||||
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
|
||||
getAlbumListCount: (args: AlbumListArgs) => Promise<number>;
|
||||
getAlbumSongList: (args: AlbumDetailArgs) => Promise<SongListResponse>; // TODO
|
||||
getArtistDetail: () => void;
|
||||
getArtistInfo: (args: any) => void;
|
||||
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||
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>;
|
||||
getPlaylistListCount: (args: PlaylistListArgs) => Promise<number>;
|
||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
||||
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||
getSongListCount: (args: SongListArgs) => Promise<number>;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||
}>;
|
||||
|
||||
+188
-1
@@ -1,8 +1,20 @@
|
||||
import { AxiosHeaders } from 'axios';
|
||||
import { z } from 'zod';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
AlbumArtistListSort,
|
||||
AlbumListSort,
|
||||
QueueSong,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import reverse from 'lodash/reverse';
|
||||
import shuffle from 'lodash/shuffle';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
|
||||
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
|
||||
@@ -38,3 +50,178 @@ export const authenticationFailure = (currentServer: ServerListItem | null) => {
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: SortOrder) => {
|
||||
let results = albums;
|
||||
|
||||
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
|
||||
|
||||
switch (sortBy) {
|
||||
case AlbumListSort.ALBUM_ARTIST:
|
||||
results = orderBy(
|
||||
results,
|
||||
['albumArtist', (v) => v.name.toLowerCase()],
|
||||
[order, 'asc'],
|
||||
);
|
||||
break;
|
||||
case AlbumListSort.DURATION:
|
||||
results = orderBy(results, ['duration'], [order]);
|
||||
break;
|
||||
case AlbumListSort.FAVORITED:
|
||||
results = orderBy(results, ['starred'], [order]);
|
||||
break;
|
||||
case AlbumListSort.NAME:
|
||||
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
|
||||
break;
|
||||
case AlbumListSort.PLAY_COUNT:
|
||||
results = orderBy(results, ['playCount'], [order]);
|
||||
break;
|
||||
case AlbumListSort.RANDOM:
|
||||
results = shuffle(results);
|
||||
break;
|
||||
case AlbumListSort.RECENTLY_ADDED:
|
||||
results = orderBy(results, ['createdAt'], [order]);
|
||||
break;
|
||||
case AlbumListSort.RECENTLY_PLAYED:
|
||||
results = orderBy(results, ['lastPlayedAt'], [order]);
|
||||
break;
|
||||
case AlbumListSort.RATING:
|
||||
results = orderBy(results, ['userRating'], [order]);
|
||||
break;
|
||||
case AlbumListSort.YEAR:
|
||||
results = orderBy(results, ['releaseYear'], [order]);
|
||||
break;
|
||||
case AlbumListSort.SONG_COUNT:
|
||||
results = orderBy(results, ['songCount'], [order]);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder: SortOrder) => {
|
||||
let results = songs;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -275,7 +275,7 @@ export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow<Playlist> } = {
|
||||
name: {
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.PLAYLISTS_DETAIL,
|
||||
route: AppRoute.PLAYLISTS_DETAIL_SONGS,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ interface UseAgGridProps<TFilter> {
|
||||
columnType?: 'albumDetail' | 'generic';
|
||||
contextMenu: SetContextMenuItems;
|
||||
customFilters?: Partial<TFilter>;
|
||||
isClientSide?: boolean;
|
||||
isClientSideSort?: boolean;
|
||||
isSearchParams?: boolean;
|
||||
itemCount?: number;
|
||||
@@ -43,6 +44,8 @@ interface UseAgGridProps<TFilter> {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
const BLOCK_SIZE = 500;
|
||||
|
||||
export const useVirtualTable = <TFilter>({
|
||||
server,
|
||||
tableRef,
|
||||
@@ -52,6 +55,7 @@ export const useVirtualTable = <TFilter>({
|
||||
itemCount,
|
||||
customFilters,
|
||||
isSearchParams,
|
||||
isClientSide,
|
||||
isClientSideSort,
|
||||
columnType,
|
||||
}: UseAgGridProps<TFilter>) => {
|
||||
@@ -182,6 +186,19 @@ export const useVirtualTable = <TFilter>({
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.totalRecordCount === null) {
|
||||
const hasMoreRows = results?.items?.length === BLOCK_SIZE;
|
||||
const lastRowIndex = hasMoreRows
|
||||
? undefined
|
||||
: (properties.filter.offset || 0) + results.items.length;
|
||||
|
||||
params.successCallback(
|
||||
results?.items || [],
|
||||
hasMoreRows ? undefined : lastRowIndex,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
params.successCallback(results?.items || [], results?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
@@ -321,6 +338,7 @@ export const useVirtualTable = <TFilter>({
|
||||
alwaysShowHorizontalScroll: true,
|
||||
autoFitColumns: properties.table.autoFit,
|
||||
blockLoadDebounceMillis: 200,
|
||||
cacheBlockSize: 500,
|
||||
getRowId: (data: GetRowIdParams<any>) => data.data.id,
|
||||
infiniteInitialRowCount: itemCount || 100,
|
||||
pagination: isPaginationEnabled,
|
||||
@@ -335,10 +353,11 @@ export const useVirtualTable = <TFilter>({
|
||||
: undefined,
|
||||
rowBuffer: 20,
|
||||
rowHeight: properties.table.rowHeight || 40,
|
||||
rowModelType: 'infinite' as RowModelType,
|
||||
rowModelType: isClientSide ? 'clientSide' : ('infinite' as RowModelType),
|
||||
suppressRowDrag: true,
|
||||
};
|
||||
}, [
|
||||
isClientSide,
|
||||
isPaginationEnabled,
|
||||
isSearchParams,
|
||||
itemCount,
|
||||
@@ -370,7 +389,9 @@ export const useVirtualTable = <TFilter>({
|
||||
);
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
|
||||
navigate(
|
||||
generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
|
||||
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
|
||||
import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters';
|
||||
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
|
||||
@@ -139,14 +140,61 @@ const FILTERS = {
|
||||
value: AlbumListSort.YEAR,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.ALBUM_ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.PLAY_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RANDOM,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RECENTLY_ADDED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.RECENTLY_PLAYED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.FAVORITED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
|
||||
value: AlbumListSort.YEAR,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface AlbumListHeaderFiltersProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount: number | undefined;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => {
|
||||
export const AlbumListHeaderFilters = ({
|
||||
gridRef,
|
||||
tableRef,
|
||||
itemCount,
|
||||
}: AlbumListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { pageKey, customFilters, handlePlay } = useListContext();
|
||||
@@ -159,6 +207,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
server,
|
||||
});
|
||||
@@ -185,27 +234,35 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
);
|
||||
|
||||
const handleOpenFiltersModal = () => {
|
||||
let FilterComponent;
|
||||
|
||||
switch (server?.type) {
|
||||
case ServerType.NAVIDROME:
|
||||
FilterComponent = NavidromeAlbumFilters;
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
FilterComponent = JellyfinAlbumFilters;
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
FilterComponent = SubsonicAlbumFilters;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!FilterComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
openModal({
|
||||
children: (
|
||||
<>
|
||||
{server?.type === ServerType.NAVIDROME ? (
|
||||
<NavidromeAlbumFilters
|
||||
customFilters={customFilters}
|
||||
disableArtistFilter={!!customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
) : (
|
||||
<JellyfinAlbumFilters
|
||||
customFilters={customFilters}
|
||||
disableArtistFilter={!!customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<FilterComponent
|
||||
customFilters={customFilters}
|
||||
disableArtistFilter={!!customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
),
|
||||
title: 'Album Filters',
|
||||
});
|
||||
@@ -341,8 +398,20 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
|
||||
filter?._custom?.jellyfin &&
|
||||
Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined);
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied;
|
||||
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]);
|
||||
const isSubsonicFilterApplied =
|
||||
server?.type === ServerType.SUBSONIC &&
|
||||
(filter.maxYear || filter.minYear || filter.genre || filter.isFavorite);
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied;
|
||||
}, [
|
||||
filter?._custom?.jellyfin,
|
||||
filter?._custom?.navidrome,
|
||||
filter.genre,
|
||||
filter.isFavorite,
|
||||
filter.maxYear,
|
||||
filter.minYear,
|
||||
server?.type,
|
||||
]);
|
||||
|
||||
const isFolderFilterApplied = useMemo(() => {
|
||||
return filter.musicFolderId !== undefined;
|
||||
|
||||
@@ -38,6 +38,7 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
server,
|
||||
});
|
||||
@@ -94,6 +95,7 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi
|
||||
<FilterBar>
|
||||
<AlbumListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
|
||||
|
||||
interface SubsonicAlbumFiltersProps {
|
||||
onFilterChange: (filters: AlbumListFilter) => void;
|
||||
pageKey: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const SubsonicAlbumFilters = ({
|
||||
onFilterChange,
|
||||
pageKey,
|
||||
serverId,
|
||||
}: SubsonicAlbumFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { filter } = useListStoreByKey({ key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
const genreListQuery = useGenreList({
|
||||
query: {
|
||||
sortBy: GenreListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.items.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const handleGenresFilter = debounce((e: string | null) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
genre: e || undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
isFavorite: e.target.checked ? true : undefined,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.isFavorite,
|
||||
},
|
||||
];
|
||||
|
||||
const handleYearFilter = debounce((e: number | string, type: 'min' | 'max') => {
|
||||
let data = {};
|
||||
|
||||
if (type === 'min') {
|
||||
data = {
|
||||
minYear: e || undefined,
|
||||
};
|
||||
} else {
|
||||
data = {
|
||||
maxYear: e || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
console.log('data', data);
|
||||
|
||||
const updatedFilters = setFilter({
|
||||
data,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
<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.genre}
|
||||
hideControls={false}
|
||||
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
||||
max={5000}
|
||||
min={0}
|
||||
onChange={(e) => handleYearFilter(e, 'min')}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultValue={filter.maxYear}
|
||||
disabled={filter.genre}
|
||||
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.genre}
|
||||
disabled={filter.minYear || filter.maxYear}
|
||||
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { AlbumListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const getAlbumListCountQuery = (query: AlbumListQuery) => {
|
||||
const filter: Record<string, unknown> = {};
|
||||
|
||||
if (query.artistIds) filter.artistIds = query.artistIds;
|
||||
if (query.maxYear) filter.maxYear = query.maxYear;
|
||||
if (query.minYear) filter.minYear = query.minYear;
|
||||
if (query.searchTerm) filter.searchTerm = query.searchTerm;
|
||||
if (query.genre) filter.genre = query.genre;
|
||||
if (query.musicFolderId) filter.musicFolderId = query.musicFolderId;
|
||||
if (query.isCompilation) filter.isCompilation = query.isCompilation;
|
||||
if (query.isFavorite) filter.isCompilation = query.isFavorite;
|
||||
|
||||
if (Object.keys(filter).length === 0) return undefined;
|
||||
|
||||
return filter;
|
||||
};
|
||||
|
||||
export const useAlbumListCount = (args: QueryHookArgs<AlbumListQuery>) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!serverId,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getAlbumListCount({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.albums.count(serverId || '', getAlbumListCountQuery(query)),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -9,12 +9,12 @@ import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
|
||||
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { useAlbumListCount } from '/@/renderer/features/albums/queries/album-list-count-query';
|
||||
|
||||
const AlbumListRoute = () => {
|
||||
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
|
||||
@@ -42,23 +42,18 @@ const AlbumListRoute = () => {
|
||||
key: pageKey,
|
||||
});
|
||||
|
||||
const itemCountCheck = useAlbumList({
|
||||
const itemCountCheck = useAlbumListCount({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...albumListFilter,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
|
||||
|
||||
const handlePlay = useCallback(
|
||||
async (args: { initialSongId?: string; playType: Play }) => {
|
||||
|
||||
@@ -100,6 +100,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
: undefined),
|
||||
},
|
||||
},
|
||||
artistIds: [albumArtistId],
|
||||
limit: 15,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
@@ -122,6 +123,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
: undefined),
|
||||
},
|
||||
},
|
||||
artistIds: [albumArtistId],
|
||||
isCompilation: true,
|
||||
limit: 15,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
|
||||
@@ -85,6 +85,28 @@ const FILTERS = {
|
||||
value: AlbumArtistListSort.SONG_COUNT,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),
|
||||
value: AlbumArtistListSort.ALBUM_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
|
||||
value: AlbumArtistListSort.FAVORITED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: AlbumArtistListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
|
||||
value: AlbumArtistListSort.RATING,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface AlbumArtistListHeaderFiltersProps {
|
||||
|
||||
@@ -35,6 +35,7 @@ export const AlbumArtistListHeader = ({
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
server,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumArtistListQuery } from '/@/renderer/api/types';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const getAlbumArtistListCountQuery = (query: AlbumArtistListQuery) => {
|
||||
const filter: Record<string, unknown> = {};
|
||||
|
||||
if (query.searchTerm) filter.searchTerm = query.searchTerm;
|
||||
if (query.musicFolderId) filter.musicFolderId = query.musicFolderId;
|
||||
|
||||
if (Object.keys(filter).length === 0) return undefined;
|
||||
|
||||
return filter;
|
||||
};
|
||||
|
||||
export const useAlbumArtistListCount = (args: QueryHookArgs<AlbumArtistListQuery>) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!serverId,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getAlbumArtistListCount({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.albumArtists.count(serverId || '', getAlbumArtistListCountQuery(query)),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content';
|
||||
import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { useAlbumArtistListCount } from '/@/renderer/features/artists/queries/album-artist-list-count-query';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
|
||||
const AlbumArtistListRoute = () => {
|
||||
@@ -18,23 +18,18 @@ const AlbumArtistListRoute = () => {
|
||||
|
||||
const albumArtistListFilter = useListFilterByKey({ key: pageKey });
|
||||
|
||||
const itemCountCheck = useAlbumArtistList({
|
||||
const itemCountCheck = useAlbumArtistListCount({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...albumArtistListFilter,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
|
||||
@@ -37,14 +37,36 @@ const FILTERS = {
|
||||
value: GenreListSort.NAME,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: GenreListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),
|
||||
value: GenreListSort.ALBUM_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),
|
||||
value: GenreListSort.SONG_COUNT,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface GenreListHeaderFiltersProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount: number | undefined;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => {
|
||||
export const GenreListHeaderFilters = ({
|
||||
gridRef,
|
||||
tableRef,
|
||||
itemCount,
|
||||
}: GenreListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { pageKey, customFilters } = useListContext();
|
||||
@@ -54,6 +76,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.GENRE,
|
||||
server,
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
|
||||
const { setFilter, setTablePagination } = useListStoreActions();
|
||||
|
||||
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.GENRE,
|
||||
server,
|
||||
});
|
||||
@@ -89,6 +90,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
|
||||
<FilterBar>
|
||||
<GenreListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
@@ -23,7 +23,6 @@ export const getPlaylistSongsById = async (args: {
|
||||
id,
|
||||
sortBy: SongListSort.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
...query,
|
||||
};
|
||||
|
||||
@@ -139,7 +138,9 @@ export const getGenreSongsById = async (args: {
|
||||
);
|
||||
|
||||
data.items.push(...res!.items);
|
||||
data.totalRecordCount += res!.totalRecordCount;
|
||||
if (data.totalRecordCount) {
|
||||
data.totalRecordCount += res!.totalRecordCount || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
@@ -136,7 +136,6 @@ export const AddToPlaylistContextModal = ({
|
||||
if (values.skipDuplicates) {
|
||||
const query = {
|
||||
id: playlistId,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
|
||||
@@ -151,7 +150,11 @@ export const AddToPlaylistContextModal = ({
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: { id: playlistId, startIndex: 0 },
|
||||
query: {
|
||||
id: playlistId,
|
||||
sortBy: SongListSort.ID,
|
||||
sortOrder: SortOrder.ASC,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
},
|
||||
comment: '',
|
||||
name: '',
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
|
||||
@@ -86,7 +87,8 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
);
|
||||
});
|
||||
|
||||
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isPublicDisplayed =
|
||||
server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC;
|
||||
const isSubmitDisabled = !form.values.name || mutation.isLoading;
|
||||
|
||||
return (
|
||||
@@ -115,7 +117,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
context: 'public',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
{...form.getInputProps('_custom.navidrome.public', {
|
||||
{...form.getInputProps('public', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
import { MutableRefObject, useMemo, useRef } from 'react';
|
||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Box, Group } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiMoreFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { useListStoreByKey } from '../../../store/list.store';
|
||||
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
||||
import { Button, ConfirmModal, DropdownMenu, MotionGroup, toast } from '/@/renderer/components';
|
||||
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import {
|
||||
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { usePlaylistSongListInfinite } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
|
||||
import { PlayButton, PLAY_TYPES } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
|
||||
}
|
||||
`;
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
tableRef: MutableRefObject<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
|
||||
stickyHeader
|
||||
suppressCellFocus
|
||||
suppressHorizontalScroll
|
||||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => {
|
||||
// It's possible that there are duplicate song ids in a playlist
|
||||
return `${data.data.id}-${data.data.pageIndex}`;
|
||||
}}
|
||||
rowClassRules={rowClassRules}
|
||||
rowData={playlistSongData}
|
||||
rowHeight={60}
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -2,25 +2,15 @@ import type {
|
||||
BodyScrollEvent,
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
LibraryItem,
|
||||
PlaylistSongListQuery,
|
||||
QueueSong,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { LibraryItem, QueueSong, Song } from '/@/renderer/api/types';
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||
import { TablePagination, VirtualTable, getColumnDefs } from '/@/renderer/components/virtual-table';
|
||||
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
|
||||
@@ -31,7 +21,7 @@ import {
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
|
||||
import { useAppFocus } from '/@/renderer/hooks';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useCurrentSong,
|
||||
@@ -43,26 +33,19 @@ import {
|
||||
} from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { useAppFocus } from '/@/renderer/hooks';
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
songs: Song[];
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailContentProps) => {
|
||||
export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetailContentProps) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const queryClient = useQueryClient();
|
||||
const status = useCurrentStatus();
|
||||
const isFocused = useAppFocus();
|
||||
const currentSong = useCurrentSong();
|
||||
const server = useCurrentServer();
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = useMemo(() => {
|
||||
return {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
};
|
||||
}, [page?.table.id, playlistId]);
|
||||
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
|
||||
@@ -82,15 +65,6 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const checkPlaylistList = usePlaylistSongList({
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns, false, 'generic'),
|
||||
[page.table.columns],
|
||||
@@ -98,44 +72,9 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
|
||||
const onGridReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const query: PlaylistSongListQuery = {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(
|
||||
server?.id || '',
|
||||
playlistId,
|
||||
query,
|
||||
);
|
||||
|
||||
if (!server) return;
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
);
|
||||
|
||||
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api?.ensureIndexVisible(pagination.scrollOffset, 'top');
|
||||
},
|
||||
[filters, pagination.scrollOffset, playlistId, queryClient, server],
|
||||
[pagination.scrollOffset],
|
||||
);
|
||||
|
||||
const handleGridSizeChange = () => {
|
||||
@@ -249,13 +188,13 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
status,
|
||||
}}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={pagination.itemsPerPage || 100}
|
||||
rowClassRules={rowClassRules}
|
||||
rowData={songs}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
rowModelType="clientSide"
|
||||
onBodyScrollEnd={handleScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleColumnChange}
|
||||
|
||||
+93
-72
@@ -1,53 +1,50 @@
|
||||
import { useCallback, ChangeEvent, MutableRefObject, MouseEvent } from 'react';
|
||||
import { IDatasource } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiMoreFill,
|
||||
RiSettings3Fill,
|
||||
RiPlayFill,
|
||||
RiAddCircleFill,
|
||||
RiAddBoxFill,
|
||||
RiEditFill,
|
||||
RiAddCircleFill,
|
||||
RiDeleteBinFill,
|
||||
RiEditFill,
|
||||
RiMoreFill,
|
||||
RiPlayFill,
|
||||
RiRefreshLine,
|
||||
RiSettings3Fill,
|
||||
} from 'react-icons/ri';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { LibraryItem, PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
Button,
|
||||
Slider,
|
||||
ConfirmModal,
|
||||
DropdownMenu,
|
||||
MultiSelect,
|
||||
Slider,
|
||||
Switch,
|
||||
Text,
|
||||
ConfirmModal,
|
||||
toast,
|
||||
} from '/@/renderer/components';
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { OrderToggleButton } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
useCurrentServer,
|
||||
SongListFilter,
|
||||
usePlaylistDetailStore,
|
||||
useSetPlaylistDetailFilters,
|
||||
useSetPlaylistDetailTable,
|
||||
useSetPlaylistStore,
|
||||
useSetPlaylistTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, ServerType, Play, TableColumn } from '/@/renderer/types';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { OrderToggleButton } from '/@/renderer/features/shared';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
@@ -150,7 +147,7 @@ const FILTERS = {
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),
|
||||
name: i18n.t('filter.genre', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.GENRE,
|
||||
},
|
||||
{
|
||||
@@ -184,6 +181,68 @@ const FILTERS = {
|
||||
value: SongListSort.YEAR,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ID,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.album', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ALBUM,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ALBUM_ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.DURATION,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.FAVORITED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.genre', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.GENRE,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RATING,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RECENTLY_ADDED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RECENTLY_PLAYED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.YEAR,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface PlaylistDetailSongListHeaderFiltersProps {
|
||||
@@ -228,56 +287,18 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
setTable({ rowHeight: e });
|
||||
};
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: SongListFilter) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
const handleFilterChange = useCallback(async () => {
|
||||
tableRef.current?.api.redrawRows();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
});
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
}
|
||||
},
|
||||
[tableRef, page.display, server, playlistId, queryClient, setPagination],
|
||||
);
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
}
|
||||
}, [tableRef, page.display, setPagination]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
|
||||
handleFilterChange({ ...page?.table.id[playlistId].filter, ...filters });
|
||||
handleFilterChange();
|
||||
};
|
||||
|
||||
const handleSetSortBy = useCallback(
|
||||
@@ -288,20 +309,20 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
(f) => f.value === e.currentTarget.value,
|
||||
)?.defaultOrder;
|
||||
|
||||
const updatedFilters = setFilter(playlistId, {
|
||||
setFilter(playlistId, {
|
||||
sortBy: e.currentTarget.value as SongListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
handleFilterChange();
|
||||
},
|
||||
[handleFilterChange, playlistId, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter(playlistId, { sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
setFilter(playlistId, { sortOrder: newSortOrder });
|
||||
handleFilterChange();
|
||||
}, [filters.sortOrder, handleFilterChange, playlistId, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { Flex, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
@@ -45,23 +45,30 @@ export const PlaylistDetailSongListHeader = ({
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? (
|
||||
<SpinnerIcon />
|
||||
) : (
|
||||
itemCount
|
||||
)}
|
||||
</Paper>
|
||||
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>}
|
||||
</LibraryHeaderBar>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton
|
||||
onClick={() => handlePlay(playButtonBehavior)}
|
||||
/>
|
||||
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? (
|
||||
<SpinnerIcon />
|
||||
) : (
|
||||
itemCount
|
||||
)}
|
||||
</Paper>
|
||||
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>}
|
||||
</LibraryHeaderBar>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
<Paper p="1rem">
|
||||
<PlaylistDetailSongListHeaderFilters
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryKey, useQueryClient } from '@tanstack/react-query';
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { QueryKey, useQueryClient } from '@tanstack/react-query';
|
||||
import AutoSizer, { Size } from 'react-virtualized-auto-sizer';
|
||||
import { ListOnScrollProps } from 'react-window';
|
||||
import { useListContext } from '../../../context/list-context';
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useGeneralSettings, useListStoreByKey } from '/@/renderer/store';
|
||||
import { useCurrentServer, useListStoreByKey } from '/@/renderer/store';
|
||||
import { CardRow, ListDisplayType } from '/@/renderer/types';
|
||||
|
||||
interface PlaylistListGridViewProps {
|
||||
@@ -37,7 +37,6 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { display, grid, filter } = useListStoreByKey({ key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const { defaultFullPlaylist } = useGeneralSettings();
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
@@ -68,9 +67,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
||||
};
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<Playlist>[] = defaultFullPlaylist
|
||||
? [PLAYLIST_CARD_ROWS.nameFull]
|
||||
: [PLAYLIST_CARD_ROWS.name];
|
||||
const rows: CardRow<Playlist>[] = [PLAYLIST_CARD_ROWS.name];
|
||||
|
||||
switch (filter.sortBy) {
|
||||
case PlaylistListSort.DURATION:
|
||||
@@ -93,7 +90,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [defaultFullPlaylist, filter.sortBy]);
|
||||
}, [filter.sortBy]);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
@@ -187,9 +184,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
|
||||
loading={itemCount === undefined || itemCount === null}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: defaultFullPlaylist
|
||||
? AppRoute.PLAYLISTS_DETAIL_SONGS
|
||||
: AppRoute.PLAYLISTS_DETAIL,
|
||||
route: AppRoute.PLAYLISTS_DETAIL_SONGS,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }],
|
||||
}}
|
||||
width={width}
|
||||
|
||||
@@ -69,6 +69,38 @@ const FILTERS = {
|
||||
value: PlaylistListSort.UPDATED_AT,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.DURATION,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.owner', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.OWNER,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.isPublic', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.PUBLIC,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.songCount', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.SONG_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyUpdated', { postProcess: 'titleCase' }),
|
||||
value: PlaylistListSort.UPDATED_AT,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface PlaylistListHeaderFiltersProps {
|
||||
|
||||
@@ -44,6 +44,7 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
|
||||
};
|
||||
|
||||
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
server,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table';
|
||||
import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
interface PlaylistListTableViewProps {
|
||||
itemCount?: number;
|
||||
@@ -18,16 +18,11 @@ interface PlaylistListTableViewProps {
|
||||
export const PlaylistListTableView = ({ tableRef, itemCount }: PlaylistListTableViewProps) => {
|
||||
const navigate = useNavigate();
|
||||
const server = useCurrentServer();
|
||||
const { defaultFullPlaylist } = useGeneralSettings();
|
||||
const pageKey = 'playlist';
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||
if (!e.data) return;
|
||||
if (defaultFullPlaylist) {
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }));
|
||||
} else {
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
|
||||
}
|
||||
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }));
|
||||
};
|
||||
|
||||
const tableProps = useVirtualTable({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/renderer/api/types';
|
||||
import type { PlaylistSongListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
@@ -23,31 +23,31 @@ export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>)
|
||||
});
|
||||
};
|
||||
|
||||
export const usePlaylistSongListInfinite = (args: QueryHookArgs<PlaylistSongListQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
// 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 useInfiniteQuery({
|
||||
// enabled: !!server,
|
||||
// getNextPageParam: (lastPage: PlaylistSongListResponse | undefined, pages) => {
|
||||
// if (!lastPage?.items) return undefined;
|
||||
// if (lastPage?.items?.length >= (query?.limit || 50)) {
|
||||
// return pages?.length;
|
||||
// }
|
||||
|
||||
return undefined;
|
||||
},
|
||||
queryFn: ({ pageParam = 0, signal }) => {
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: { server, signal },
|
||||
query: {
|
||||
...query,
|
||||
limit: query.limit || 50,
|
||||
startIndex: pageParam * (query.limit || 50),
|
||||
},
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
// return undefined;
|
||||
// },
|
||||
// queryFn: ({ pageParam = 0, signal }) => {
|
||||
// return api.controller.getPlaylistSongList({
|
||||
// apiClientProps: { server, signal },
|
||||
// query: {
|
||||
// ...query,
|
||||
// limit: query.limit || 50,
|
||||
// startIndex: pageParam * (query.limit || 50),
|
||||
// },
|
||||
// });
|
||||
// },
|
||||
// queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
|
||||
// ...options,
|
||||
// });
|
||||
// };
|
||||
|
||||
@@ -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;
|
||||
@@ -139,28 +139,20 @@ const PlaylistDetailSongListRoute = () => {
|
||||
|
||||
const page = usePlaylistDetailStore();
|
||||
const filters: Partial<PlaylistSongListQuery> = {
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
sortBy: page?.table.id[playlistId]?.filter?.sortBy,
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder,
|
||||
};
|
||||
|
||||
const itemCountCheck = usePlaylistSongList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
},
|
||||
const { data } = usePlaylistSongList({
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...filters,
|
||||
sortBy: filters.sortBy || SongListSort.ID,
|
||||
sortOrder: filters.sortOrder || SortOrder.ASC,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
const itemCount = data?.items.length;
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
|
||||
@@ -206,7 +198,10 @@ const PlaylistDetailSongListRoute = () => {
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
<PlaylistDetailSongListContent tableRef={tableRef} />
|
||||
<PlaylistDetailSongListContent
|
||||
songs={data?.items || []}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||
const SERVER_TYPES = [
|
||||
{ label: 'Jellyfin', value: ServerType.JELLYFIN },
|
||||
{ label: 'Navidrome', value: ServerType.NAVIDROME },
|
||||
// { label: 'Subsonic', value: ServerType.SUBSONIC },
|
||||
{ label: 'Subsonic', value: ServerType.SUBSONIC },
|
||||
];
|
||||
|
||||
interface AddServerFormProps {
|
||||
|
||||
@@ -246,28 +246,6 @@ export const ControlSettings = () => {
|
||||
isHidden: !isElectron(),
|
||||
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' }),
|
||||
},
|
||||
];
|
||||
|
||||
return <SettingsSection options={controlOptions} />;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { RiAddBoxFill, RiAddCircleFill, RiPlayFill } from 'react-icons/ri';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { LibraryItem, PlaylistListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { usePlaylistList } from '/@/renderer/features/playlists';
|
||||
@@ -14,20 +14,12 @@ import { Play } from '/@/renderer/types';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeList, ListChildComponentProps } from 'react-window';
|
||||
import { useHideScrollbar } from '/@/renderer/hooks';
|
||||
import { useGeneralSettings } from '/@/renderer/store';
|
||||
|
||||
interface SidebarPlaylistListProps {
|
||||
data: ReturnType<typeof usePlaylistList>['data'];
|
||||
}
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const path = data?.items[index].id
|
||||
? data.defaultFullPlaylist
|
||||
? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id })
|
||||
: generatePath(AppRoute.PLAYLISTS_DETAIL, {
|
||||
playlistId: data?.items[index].id,
|
||||
})
|
||||
? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id })
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
@@ -121,10 +113,19 @@ const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
|
||||
export const SidebarPlaylistList = () => {
|
||||
const { isScrollbarHidden, hideScrollbarElementProps } = useHideScrollbar(0);
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { defaultFullPlaylist } = useGeneralSettings();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const playlistsQuery = usePlaylistList({
|
||||
query: {
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const [rect, setRect] = useState({
|
||||
height: 0,
|
||||
@@ -148,11 +149,10 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
|
||||
|
||||
const memoizedItemData = useMemo(() => {
|
||||
return {
|
||||
defaultFullPlaylist,
|
||||
handlePlay: handlePlayPlaylist,
|
||||
items: data?.items,
|
||||
items: playlistsQuery?.data?.items,
|
||||
};
|
||||
}, [data?.items, defaultFullPlaylist, handlePlayPlaylist]);
|
||||
}, [playlistsQuery?.data?.items, handlePlayPlaylist]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -168,7 +168,7 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
|
||||
: 'overlay-scrollbar'
|
||||
}
|
||||
height={debounced.height}
|
||||
itemCount={data?.items?.length || 0}
|
||||
itemCount={playlistsQuery?.data?.items?.length || 0}
|
||||
itemData={memoizedItemData}
|
||||
itemSize={25}
|
||||
overscanCount={20}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MouseEvent, useMemo } from 'react';
|
||||
import { Box, Center, Divider, Group, Stack } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { MouseEvent, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiAddFill, RiArrowDownSLine, RiDiscLine, RiListUnordered } from 'react-icons/ri';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
useGeneralSettings,
|
||||
useWindowSettings,
|
||||
} from '../../../store/settings.store';
|
||||
import { PlaylistListSort, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import { Button, MotionStack, Spinner, Tooltip } from '/@/renderer/components';
|
||||
import { CreatePlaylistForm, usePlaylistList } from '/@/renderer/features/playlists';
|
||||
import { ServerType } from '/@/renderer/api/types';
|
||||
import { Button, MotionStack, Tooltip } from '/@/renderer/components';
|
||||
import { CreatePlaylistForm } from '/@/renderer/features/playlists';
|
||||
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
|
||||
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||
@@ -110,15 +110,6 @@ export const Sidebar = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const playlistsQuery = usePlaylistList({
|
||||
query: {
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
const expandFullScreenPlayer = () => {
|
||||
@@ -198,7 +189,6 @@ export const Sidebar = () => {
|
||||
>
|
||||
{t('page.sidebar.playlists', { postProcess: 'titleCase' })}
|
||||
</Box>
|
||||
{playlistsQuery.isLoading && <Spinner />}
|
||||
</Group>
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
@@ -233,7 +223,7 @@ export const Sidebar = () => {
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<SidebarPlaylistList data={playlistsQuery.data} />
|
||||
<SidebarPlaylistList />
|
||||
</>
|
||||
)}
|
||||
</MotionStack>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
|
||||
import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { SubsonicSongFilters } from '/@/renderer/features/songs/components/subsonic-song-filters';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
@@ -160,14 +161,26 @@ const FILTERS = {
|
||||
value: SongListSort.YEAR,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.NAME,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface SongListHeaderFiltersProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
itemCount: number | undefined;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFiltersProps) => {
|
||||
export const SongListHeaderFilters = ({
|
||||
gridRef,
|
||||
tableRef,
|
||||
itemCount,
|
||||
}: SongListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const { pageKey, handlePlay, customFilters } = useListContext();
|
||||
@@ -179,6 +192,7 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
|
||||
useListStoreActions();
|
||||
|
||||
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.SONG,
|
||||
server,
|
||||
});
|
||||
@@ -387,25 +401,34 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
|
||||
};
|
||||
|
||||
const handleOpenFiltersModal = () => {
|
||||
let FilterComponent;
|
||||
|
||||
switch (server?.type) {
|
||||
case ServerType.NAVIDROME:
|
||||
FilterComponent = NavidromeSongFilters;
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
FilterComponent = JellyfinSongFilters;
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
FilterComponent = SubsonicSongFilters;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!FilterComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
openModal({
|
||||
children: (
|
||||
<>
|
||||
{server?.type === ServerType.NAVIDROME ? (
|
||||
<NavidromeSongFilters
|
||||
customFilters={customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
) : (
|
||||
<JellyfinSongFilters
|
||||
customFilters={customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<FilterComponent
|
||||
customFilters={customFilters}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
),
|
||||
title: 'Song Filters',
|
||||
});
|
||||
@@ -424,8 +447,17 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
|
||||
.filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio
|
||||
.some((value) => value !== undefined);
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied;
|
||||
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]);
|
||||
const isSubsonicFilterApplied =
|
||||
server?.type === ServerType.SUBSONIC && (filter?.isFavorite || filter?.genre);
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied;
|
||||
}, [
|
||||
filter._custom?.jellyfin,
|
||||
filter._custom?.navidrome,
|
||||
filter?.genre,
|
||||
filter?.isFavorite,
|
||||
server?.type,
|
||||
]);
|
||||
|
||||
const isFolderFilterApplied = useMemo(() => {
|
||||
return filter.musicFolderId !== undefined;
|
||||
@@ -462,11 +494,15 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Divider orientation="vertical" />
|
||||
<OrderToggleButton
|
||||
sortOrder={filter.sortOrder}
|
||||
onToggle={handleToggleSortOrder}
|
||||
/>
|
||||
{server?.type !== ServerType.SUBSONIC && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<OrderToggleButton
|
||||
sortOrder={filter.sortOrder}
|
||||
onToggle={handleToggleSortOrder}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
|
||||
@@ -32,6 +32,7 @@ export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongList
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.SONG,
|
||||
server,
|
||||
});
|
||||
@@ -94,6 +95,7 @@ export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongList
|
||||
<FilterBar>
|
||||
<SongListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { 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({ key: pageKey });
|
||||
|
||||
const isGenrePage = customFilters?._custom?.navidrome?.genre_id !== 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: {
|
||||
genre: e || undefined,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
isFavorite: e.target.checked,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.isFavorite,
|
||||
},
|
||||
];
|
||||
|
||||
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}
|
||||
size="xs"
|
||||
onChange={filter.onChange}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
{!isGenrePage && (
|
||||
<Select
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={filter.genre}
|
||||
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
||||
width={150}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { SongListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const getSongListCountQuery = (query: SongListQuery) => {
|
||||
const filter: Record<string, unknown> = {};
|
||||
|
||||
if (query.searchTerm) filter.searchTerm = query.searchTerm;
|
||||
if (query.genreId) filter.genreId = query.genreId;
|
||||
if (query.musicFolderId) filter.musicFolderId = query.musicFolderId;
|
||||
if (query.isFavorite) filter.isFavorite = query.isFavorite;
|
||||
if (query.genre) filter.genre = query.genre;
|
||||
|
||||
if (Object.keys(filter).length === 0) return undefined;
|
||||
|
||||
return filter;
|
||||
};
|
||||
|
||||
export const useSongListCount = (args: QueryHookArgs<SongListQuery>) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!serverId,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getSongListCount({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.songs.count(serverId || '', getSongListCountQuery(query)),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -9,11 +9,11 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { SongListContent } from '/@/renderer/features/songs/components/song-list-content';
|
||||
import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header';
|
||||
import { useSongList } from '/@/renderer/features/songs/queries/song-list-query';
|
||||
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { titleCase } from '/@/renderer/utils';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { useSongListCount } from '/@/renderer/features/songs/queries/song-list-count-query';
|
||||
|
||||
const TrackListRoute = () => {
|
||||
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
|
||||
@@ -36,6 +36,8 @@ const TrackListRoute = () => {
|
||||
genre_id: genreId,
|
||||
},
|
||||
},
|
||||
genre: genreId,
|
||||
genreId,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -74,7 +76,7 @@ const TrackListRoute = () => {
|
||||
return genre?.name;
|
||||
}, [genreId, genreList.data]);
|
||||
|
||||
const itemCountCheck = useSongList({
|
||||
const itemCountCheck = useSongListCount({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
@@ -87,10 +89,7 @@ const TrackListRoute = () => {
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
|
||||
|
||||
const handlePlay = useCallback(
|
||||
async (args: { initialSongId?: string; playType: Play }) => {
|
||||
|
||||
@@ -10,14 +10,18 @@ import orderBy from 'lodash/orderBy';
|
||||
|
||||
interface UseHandleListFilterChangeProps {
|
||||
isClientSideSort?: boolean;
|
||||
itemCount?: number;
|
||||
itemType: LibraryItem;
|
||||
server: ServerListItem | null;
|
||||
}
|
||||
|
||||
const BLOCK_SIZE = 500;
|
||||
|
||||
export const useListFilterRefresh = ({
|
||||
server,
|
||||
itemType,
|
||||
isClientSideSort,
|
||||
itemCount,
|
||||
}: UseHandleListFilterChangeProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -78,7 +82,7 @@ export const useListFilterRefresh = ({
|
||||
|
||||
const queryKey = queryKeyFn(server?.id || '', query);
|
||||
|
||||
const res = await queryClient.fetchQuery({
|
||||
const results = (await queryClient.fetchQuery({
|
||||
queryFn: async ({ signal }) => {
|
||||
return queryFn({
|
||||
apiClientProps: {
|
||||
@@ -89,20 +93,39 @@ export const useListFilterRefresh = ({
|
||||
});
|
||||
},
|
||||
queryKey,
|
||||
});
|
||||
})) as BasePaginatedResponse<any>;
|
||||
|
||||
if (isClientSideSort && res?.items) {
|
||||
if (isClientSideSort && results?.items) {
|
||||
const sortedResults = orderBy(
|
||||
res.items,
|
||||
results.items,
|
||||
[(item) => String(item[filter.sortBy]).toLowerCase()],
|
||||
filter.sortOrder === 'DESC' ? ['desc'] : ['asc'],
|
||||
);
|
||||
|
||||
params.successCallback(sortedResults || [], res?.totalRecordCount || 0);
|
||||
params.successCallback(
|
||||
sortedResults || [],
|
||||
results?.totalRecordCount || itemCount,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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 || itemCount,
|
||||
);
|
||||
},
|
||||
|
||||
rowCount: undefined,
|
||||
@@ -112,7 +135,7 @@ export const useListFilterRefresh = ({
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
},
|
||||
[isClientSideSort, queryClient, queryFn, queryKeyFn, server],
|
||||
[isClientSideSort, itemCount, queryClient, queryFn, queryKeyFn, server],
|
||||
);
|
||||
|
||||
const handleRefreshGrid = useCallback(
|
||||
|
||||
@@ -18,10 +18,6 @@ const AlbumListRoute = lazy(() => import('/@/renderer/features/albums/routes/alb
|
||||
|
||||
const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route'));
|
||||
|
||||
const PlaylistDetailRoute = lazy(
|
||||
() => import('/@/renderer/features/playlists/routes/playlist-detail-route'),
|
||||
);
|
||||
|
||||
const PlaylistDetailSongListRoute = lazy(
|
||||
() => import('/@/renderer/features/playlists/routes/playlist-detail-song-list-route'),
|
||||
);
|
||||
@@ -136,11 +132,6 @@ export const AppRouter = () => {
|
||||
errorElement={<RouteErrorBoundary />}
|
||||
path={AppRoute.PLAYLISTS}
|
||||
/>
|
||||
<Route
|
||||
element={<PlaylistDetailRoute />}
|
||||
errorElement={<RouteErrorBoundary />}
|
||||
path={AppRoute.PLAYLISTS_DETAIL}
|
||||
/>
|
||||
<Route
|
||||
element={<PlaylistDetailSongListRoute />}
|
||||
errorElement={<RouteErrorBoundary />}
|
||||
|
||||
Reference in New Issue
Block a user