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 orderBy from 'lodash/orderBy';
import md5 from 'md5';
@@ -13,7 +14,7 @@ import {
LibraryItem,
PlaylistListSort,
} from '/@/renderer/api/types';
import { sortAlbumArtistList, sortSongList } from '/@/renderer/api/utils';
import { sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/renderer/api/utils';
import { randomString } from '/@/renderer/utils';
const authenticate = async (
@@ -292,6 +293,36 @@ export const SubsonicController: ControllerEndpoint = {
getAlbumList: async (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> = {
[AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM,
[AlbumListSort.ALBUM_ARTIST]:
@@ -312,13 +343,9 @@ export const SubsonicController: ControllerEndpoint = {
[AlbumListSort.SONG_COUNT]: undefined,
};
if (query.isCompilation) {
return {
items: [],
startIndex: 0,
totalRecordCount: 0,
};
}
let type =
sortType[query.sortBy] ??
SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME;
if (query.artistIds) {
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({
query: {
fromYear: query.minYear,
fromYear,
genre: query.genre,
musicFolderId: query.musicFolderId,
offset: query.startIndex,
size: query.limit,
toYear: query.maxYear,
type:
sortType[query.sortBy] ??
SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME,
toYear,
type,
},
});
@@ -371,9 +444,10 @@ export const SubsonicController: ControllerEndpoint = {
}
return {
items: res.body['subsonic-response'].albumList2.album.map((album) =>
subsonicNormalize.album(album, apiClientProps.server),
),
items:
res.body['subsonic-response'].albumList2.album?.map((album) =>
subsonicNormalize.album(album, apiClientProps.server, 300),
) || [],
startIndex: query.startIndex,
totalRecordCount: null,
};
@@ -381,6 +455,41 @@ export const SubsonicController: ControllerEndpoint = {
getAlbumListCount: async (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> = {
[AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM,
[AlbumListSort.ALBUM_ARTIST]:
@@ -401,22 +510,63 @@ export const SubsonicController: ControllerEndpoint = {
[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 startIndex = 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) {
const res = await subsonicApiClient(apiClientProps).getAlbumList2({
query: {
fromYear: query.minYear,
fromYear,
genre: query.genre,
musicFolderId: query.musicFolderId,
offset: startIndex,
size: 500,
toYear: query.maxYear,
type:
sortType[query.sortBy] ??
SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME,
toYear,
type,
},
});
@@ -153,13 +153,14 @@ const normalizeAlbum = (
| z.infer<typeof SubsonicApi._baseTypes.album>
| z.infer<typeof SubsonicApi._baseTypes.albumListEntry>,
server: ServerListItem | null,
size?: number,
): Album => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 300,
size: size || 300,
}) || null;
return {
+1
View File
@@ -378,6 +378,7 @@ export type AlbumListQuery = {
artistIds?: string[];
genre?: string;
isCompilation?: boolean;
isFavorite?: boolean;
limit?: number;
maxYear?: number;
minYear?: number;
+52
View File
@@ -3,8 +3,10 @@ import { toast } from '/@/renderer/components';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
import {
Album,
AlbumArtist,
AlbumArtistListSort,
AlbumListSort,
QueueSong,
SongListSort,
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) => {
let results = songs;
@@ -183,15 +183,14 @@ export const useVirtualTable = <TFilter>({
}
if (results.totalRecordCount === null) {
const totalRecordCount: number | undefined = itemCount;
const hasMoreRows = results?.items?.length === properties.filter.limit;
const lastRowIndex = hasMoreRows
? undefined
: properties.filter.offset + results.items.length;
: (properties.filter.offset || 0) + results.items.length;
params.successCallback(
results?.items || [],
totalRecordCount || lastRowIndex,
hasMoreRows ? undefined : lastRowIndex,
);
return;
}
@@ -212,7 +211,6 @@ export const useVirtualTable = <TFilter>({
queryClient,
isClientSideSort,
queryFn,
itemCount,
],
);
@@ -22,6 +22,7 @@ import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { useListContext } from '/@/renderer/context/list-context';
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters';
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
@@ -233,27 +234,35 @@ export const AlbumListHeaderFilters = ({
);
const handleOpenFiltersModal = () => {
let FilterComponent;
switch (server?.type) {
case ServerType.NAVIDROME:
FilterComponent = NavidromeAlbumFilters;
break;
case ServerType.JELLYFIN:
FilterComponent = JellyfinAlbumFilters;
break;
case ServerType.SUBSONIC:
FilterComponent = SubsonicAlbumFilters;
break;
default:
break;
}
if (!FilterComponent) {
return;
}
openModal({
children: (
<>
{server?.type === ServerType.NAVIDROME ? (
<NavidromeAlbumFilters
customFilters={customFilters}
disableArtistFilter={!!customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
) : (
<JellyfinAlbumFilters
customFilters={customFilters}
disableArtistFilter={!!customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
)}
</>
<FilterComponent
customFilters={customFilters}
disableArtistFilter={!!customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
),
title: 'Album Filters',
});
@@ -389,8 +398,20 @@ export const AlbumListHeaderFilters = ({
filter?._custom?.jellyfin &&
Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined);
return isNavidromeFilterApplied || isJellyfinFilterApplied;
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]);
const isSubsonicFilterApplied =
server?.type === ServerType.SUBSONIC &&
(filter.maxYear || filter.minYear || filter.genre || filter.isFavorite);
return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied;
}, [
filter?._custom?.jellyfin,
filter?._custom?.navidrome,
filter.genre,
filter.isFavorite,
filter.maxYear,
filter.minYear,
server?.type,
]);
const isFolderFilterApplied = useMemo(() => {
return filter.musicFolderId !== undefined;
@@ -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.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;
+22 -6
View File
@@ -80,7 +80,7 @@ export const useListFilterRefresh = ({
const queryKey = queryKeyFn(server?.id || '', query);
const res = await queryClient.fetchQuery({
const results = (await queryClient.fetchQuery({
queryFn: async ({ signal }) => {
return queryFn({
apiClientProps: {
@@ -91,23 +91,39 @@ export const useListFilterRefresh = ({
});
},
queryKey,
});
})) as BasePaginatedResponse<any>;
if (isClientSideSort && res?.items) {
if (isClientSideSort && results?.items) {
const sortedResults = orderBy(
res.items,
results.items,
[(item) => String(item[filter.sortBy]).toLowerCase()],
filter.sortOrder === 'DESC' ? ['desc'] : ['asc'],
);
params.successCallback(
sortedResults || [],
res?.totalRecordCount || itemCount,
results?.totalRecordCount || itemCount,
);
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,