Support subsonic album filters

This commit is contained in:
jeffvli
2023-12-18 12:02:41 -08:00
parent 4051e9dfa3
commit f7fcf6c079
9 changed files with 438 additions and 53 deletions
+171 -21
View File
@@ -1,3 +1,4 @@
import dayjs from 'dayjs';
import filter from 'lodash/filter'; import filter from 'lodash/filter';
import orderBy from 'lodash/orderBy'; import orderBy from 'lodash/orderBy';
import md5 from 'md5'; import md5 from 'md5';
@@ -13,7 +14,7 @@ import {
LibraryItem, LibraryItem,
PlaylistListSort, PlaylistListSort,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { sortAlbumArtistList, sortSongList } from '/@/renderer/api/utils'; import { sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/renderer/api/utils';
import { randomString } from '/@/renderer/utils'; import { randomString } from '/@/renderer/utils';
const authenticate = async ( const authenticate = async (
@@ -292,6 +293,36 @@ export const SubsonicController: ControllerEndpoint = {
getAlbumList: async (args) => { getAlbumList: async (args) => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
if (query.searchTerm) {
const res = await subsonicApiClient(apiClientProps).search3({
query: {
albumCount: query.limit,
albumOffset: query.startIndex,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
songCount: 0,
songOffset: 0,
},
});
if (res.status !== 200) {
fsLog.error('Failed to get album list');
throw new Error('Failed to get album list');
}
const results =
res.body['subsonic-response'].searchResult3.album?.map((album) =>
subsonicNormalize.album(album, apiClientProps.server),
) || [];
return {
items: results,
startIndex: query.startIndex,
totalRecordCount: null,
};
}
const sortType: Record<AlbumListSort, AlbumListSortType | undefined> = { const sortType: Record<AlbumListSort, AlbumListSortType | undefined> = {
[AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM, [AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM,
[AlbumListSort.ALBUM_ARTIST]: [AlbumListSort.ALBUM_ARTIST]:
@@ -312,13 +343,9 @@ export const SubsonicController: ControllerEndpoint = {
[AlbumListSort.SONG_COUNT]: undefined, [AlbumListSort.SONG_COUNT]: undefined,
}; };
if (query.isCompilation) { let type =
return { sortType[query.sortBy] ??
items: [], SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME;
startIndex: 0,
totalRecordCount: 0,
};
}
if (query.artistIds) { if (query.artistIds) {
const promises = []; const promises = [];
@@ -351,17 +378,63 @@ export const SubsonicController: ControllerEndpoint = {
}; };
} }
if (query.isFavorite) {
const res = await subsonicApiClient(apiClientProps).getStarred({
query: {
musicFolderId: query.musicFolderId,
},
});
if (res.status !== 200) {
fsLog.error('Failed to get album list');
throw new Error('Failed to get album list');
}
const results =
res.body['subsonic-response'].starred.album?.map((album) =>
subsonicNormalize.album(album, apiClientProps.server),
) || [];
return {
items: sortAlbumList(results, query.sortBy, query.sortOrder),
startIndex: 0,
totalRecordCount: res.body['subsonic-response'].starred.album?.length || 0,
};
}
if (query.genre) {
type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_GENRE;
}
if (query.minYear || query.maxYear) {
type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_YEAR;
}
let fromYear;
let toYear;
if (query.minYear) {
fromYear = query.minYear;
toYear = dayjs().year();
}
if (query.maxYear) {
toYear = query.maxYear;
if (!query.minYear) {
fromYear = 0;
}
}
const res = await subsonicApiClient(apiClientProps).getAlbumList2({ const res = await subsonicApiClient(apiClientProps).getAlbumList2({
query: { query: {
fromYear: query.minYear, fromYear,
genre: query.genre, genre: query.genre,
musicFolderId: query.musicFolderId, musicFolderId: query.musicFolderId,
offset: query.startIndex, offset: query.startIndex,
size: query.limit, size: query.limit,
toYear: query.maxYear, toYear,
type: type,
sortType[query.sortBy] ??
SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME,
}, },
}); });
@@ -371,9 +444,10 @@ export const SubsonicController: ControllerEndpoint = {
} }
return { return {
items: res.body['subsonic-response'].albumList2.album.map((album) => items:
subsonicNormalize.album(album, apiClientProps.server), res.body['subsonic-response'].albumList2.album?.map((album) =>
), subsonicNormalize.album(album, apiClientProps.server, 300),
) || [],
startIndex: query.startIndex, startIndex: query.startIndex,
totalRecordCount: null, totalRecordCount: null,
}; };
@@ -381,6 +455,41 @@ export const SubsonicController: ControllerEndpoint = {
getAlbumListCount: async (args) => { getAlbumListCount: async (args) => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
if (query.searchTerm) {
let fetchNextPage = true;
let startIndex = 0;
let totalRecordCount = 0;
while (fetchNextPage) {
const res = await subsonicApiClient(apiClientProps).search3({
query: {
albumCount: 500,
albumOffset: startIndex,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
songCount: 0,
songOffset: 0,
},
});
if (res.status !== 200) {
fsLog.error('Failed to get album list count');
throw new Error('Failed to get album list count');
}
const albumCount = res.body['subsonic-response'].searchResult3.album?.length;
totalRecordCount += albumCount;
startIndex += albumCount;
// The max limit size for Subsonic is 500
fetchNextPage = albumCount === 500;
}
return totalRecordCount;
}
const sortType: Record<AlbumListSort, AlbumListSortType | undefined> = { const sortType: Record<AlbumListSort, AlbumListSortType | undefined> = {
[AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM, [AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM,
[AlbumListSort.ALBUM_ARTIST]: [AlbumListSort.ALBUM_ARTIST]:
@@ -401,22 +510,63 @@ export const SubsonicController: ControllerEndpoint = {
[AlbumListSort.SONG_COUNT]: undefined, [AlbumListSort.SONG_COUNT]: undefined,
}; };
if (query.isFavorite) {
const res = await subsonicApiClient(apiClientProps).getStarred({
query: {
musicFolderId: query.musicFolderId,
},
});
if (res.status !== 200) {
fsLog.error('Failed to get album list');
throw new Error('Failed to get album list');
}
return res.body['subsonic-response'].starred.album?.length || 0;
}
let type =
sortType[query.sortBy] ??
SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME;
let fetchNextPage = true; let fetchNextPage = true;
let startIndex = 0; let startIndex = 0;
let totalRecordCount = 0; let totalRecordCount = 0;
if (query.genre) {
type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_GENRE;
}
if (query.minYear || query.maxYear) {
type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_YEAR;
}
let fromYear;
let toYear;
if (query.minYear) {
fromYear = query.minYear;
toYear = dayjs().year();
}
if (query.maxYear) {
toYear = query.maxYear;
if (!query.minYear) {
fromYear = 0;
}
}
while (fetchNextPage) { while (fetchNextPage) {
const res = await subsonicApiClient(apiClientProps).getAlbumList2({ const res = await subsonicApiClient(apiClientProps).getAlbumList2({
query: { query: {
fromYear: query.minYear, fromYear,
genre: query.genre, genre: query.genre,
musicFolderId: query.musicFolderId, musicFolderId: query.musicFolderId,
offset: startIndex, offset: startIndex,
size: 500, size: 500,
toYear: query.maxYear, toYear,
type: type,
sortType[query.sortBy] ??
SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME,
}, },
}); });
@@ -153,13 +153,14 @@ const normalizeAlbum = (
| z.infer<typeof SubsonicApi._baseTypes.album> | z.infer<typeof SubsonicApi._baseTypes.album>
| z.infer<typeof SubsonicApi._baseTypes.albumListEntry>, | 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 {
+1
View File
@@ -378,6 +378,7 @@ export type AlbumListQuery = {
artistIds?: string[]; artistIds?: string[];
genre?: string; genre?: string;
isCompilation?: boolean; isCompilation?: boolean;
isFavorite?: boolean;
limit?: number; limit?: number;
maxYear?: number; maxYear?: number;
minYear?: number; minYear?: number;
+52
View File
@@ -3,8 +3,10 @@ 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 { import {
Album,
AlbumArtist, AlbumArtist,
AlbumArtistListSort, AlbumArtistListSort,
AlbumListSort,
QueueSong, QueueSong,
SongListSort, SongListSort,
SortOrder, SortOrder,
@@ -49,6 +51,56 @@ export const authenticationFailure = (currentServer: ServerListItem | 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) => { export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder: SortOrder) => {
let results = songs; let results = songs;
@@ -183,15 +183,14 @@ export const useVirtualTable = <TFilter>({
} }
if (results.totalRecordCount === null) { if (results.totalRecordCount === null) {
const totalRecordCount: number | undefined = itemCount;
const hasMoreRows = results?.items?.length === properties.filter.limit; const hasMoreRows = results?.items?.length === properties.filter.limit;
const lastRowIndex = hasMoreRows const lastRowIndex = hasMoreRows
? undefined ? undefined
: properties.filter.offset + results.items.length; : (properties.filter.offset || 0) + results.items.length;
params.successCallback( params.successCallback(
results?.items || [], results?.items || [],
totalRecordCount || lastRowIndex, hasMoreRows ? undefined : lastRowIndex,
); );
return; return;
} }
@@ -212,7 +211,6 @@ export const useVirtualTable = <TFilter>({
queryClient, queryClient,
isClientSideSort, isClientSideSort,
queryFn, queryFn,
itemCount,
], ],
); );
@@ -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';
@@ -233,27 +234,35 @@ export const AlbumListHeaderFilters = ({
); );
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',
}); });
@@ -389,8 +398,20 @@ export const AlbumListHeaderFilters = ({
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;
@@ -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>
);
};
@@ -12,7 +12,10 @@ export const getAlbumListCountQuery = (query: AlbumListQuery) => {
if (query.maxYear) filter.maxYear = query.maxYear; if (query.maxYear) filter.maxYear = query.maxYear;
if (query.minYear) filter.minYear = query.minYear; if (query.minYear) filter.minYear = query.minYear;
if (query.searchTerm) filter.searchTerm = query.searchTerm; if (query.searchTerm) filter.searchTerm = query.searchTerm;
if (query.genre) filter.genre = query.genre;
if (query.musicFolderId) filter.musicFolderId = query.musicFolderId; 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; if (Object.keys(filter).length === 0) return undefined;
+22 -6
View File
@@ -80,7 +80,7 @@ export const useListFilterRefresh = ({
const queryKey = queryKeyFn(server?.id || '', query); const queryKey = queryKeyFn(server?.id || '', query);
const res = await queryClient.fetchQuery({ const results = (await queryClient.fetchQuery({
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
return queryFn({ return queryFn({
apiClientProps: { apiClientProps: {
@@ -91,23 +91,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( params.successCallback(
sortedResults || [], sortedResults || [],
res?.totalRecordCount || itemCount, results?.totalRecordCount || itemCount,
); );
return; return;
} }
params.successCallback(res?.items || [], res?.totalRecordCount || itemCount); if (results.totalRecordCount === null) {
const hasMoreRows = results?.items?.length === filter.limit;
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,