Compare commits

...

23 Commits

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