diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index e06b38202..2e86ff834 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1599,6 +1599,76 @@ export const SubsonicController: InternalControllerEndpoint = { return (res.body.starred?.song || []).length || 0; } + const artistIds = query.albumArtistIds || query.artistIds; + + if (query.albumIds || artistIds) { + const fromAlbumPromises: Promise>[] = []; + const artistDetailPromises: Promise>[] = + []; + + if (query.albumIds) { + for (const albumId of query.albumIds) { + fromAlbumPromises.push( + ssApiClient(apiClientProps).getAlbum({ + query: { + id: albumId, + }, + }), + ); + } + } + + if (artistIds) { + for (const artistId of artistIds) { + artistDetailPromises.push( + ssApiClient(apiClientProps).getArtist({ + query: { + id: artistId, + }, + }), + ); + } + + const artistResult = await Promise.all(artistDetailPromises); + + const albums = artistResult.flatMap((artist) => { + if (artist.status !== 200) { + return []; + } + + return artist.body.artist.album ?? []; + }); + + const albumIds = albums.map((album) => album.id); + + for (const albumId of albumIds) { + fromAlbumPromises.push( + ssApiClient(apiClientProps).getAlbum({ + query: { + id: albumId.toString(), + }, + }), + ); + } + } + + let results: z.infer[] = []; + + if (fromAlbumPromises.length > 0) { + const albumsResult = await Promise.all(fromAlbumPromises); + + results = albumsResult.flatMap((album) => { + if (album.status !== 200) { + return []; + } + + return album.body.album.song; + }); + } + + return results.length; + } + let totalRecordCount = 0; // Rather than just do `search3` by groups of 500, instead diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx index 10b54784b..de56cad93 100644 --- a/src/renderer/features/albums/components/subsonic-album-filters.tsx +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -74,11 +74,22 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte })); }, [items]); + const hasFavorite = query.favorite === true; + const hasArtist = query.artistIds && query.artistIds.length > 0; + const hasGenre = query.genreIds && query.genreIds.length > 0; + const hasYear = query.minYear !== undefined || query.maxYear !== undefined; + + const isFavoriteDisabled = hasArtist || hasGenre || hasYear; + const isArtistDisabled = hasFavorite || hasGenre || hasYear; + const isGenreDisabled = hasFavorite || hasArtist || hasYear; + const isYearDisabled = hasFavorite || hasArtist || hasGenre; + const handleAlbumArtistFilter = useCallback( (e: null | string[]) => { + if (isArtistDisabled && e !== null) return; setAlbumArtist(e ?? null); }, - [setAlbumArtist], + [isArtistDisabled, setAlbumArtist], ); const genreListQuery = useGenreList(); @@ -97,13 +108,14 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte const handleGenresFilter = useCallback( (e: null | string[]) => { + if (isGenreDisabled && e !== null && e.length > 0) return; // Prevent setting if disabled if (e && e.length > 0) { setGenreId([e[0]]); } else { setGenreId(null); } }, - [setGenreId], + [isGenreDisabled, setGenreId], ); const genreFilterLabel = useMemo(() => { @@ -119,17 +131,23 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte { label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), onChange: (e: ChangeEvent) => { + if (isFavoriteDisabled && e.target.checked) return; // Prevent setting if disabled const favoriteValue = e.target.checked ? true : undefined; setFavorite(favoriteValue ?? null); }, value: query.favorite, }, ], - [t, query.favorite, setFavorite], + [isFavoriteDisabled, query.favorite, setFavorite, t], ); const handleMinYearFilter = useMemo( () => (e: number | string) => { + if (isYearDisabled) { + const isEmpty = e === '' || e === null || e === undefined || isNaN(Number(e)); + if (!isEmpty) return; + } + // Handle empty string, null, undefined, or invalid numbers as clearing if (e === '' || e === null || e === undefined || isNaN(Number(e))) { setMinYear(null); @@ -144,11 +162,16 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte setMinYear(null); } }, - [setMinYear], + [isYearDisabled, setMinYear], ); const handleMaxYearFilter = useMemo( () => (e: number | string) => { + if (isYearDisabled) { + const isEmpty = e === '' || e === null || e === undefined || isNaN(Number(e)); + if (!isEmpty) return; + } + // Handle empty string, null, undefined, or invalid numbers as clearing if (e === '' || e === null || e === undefined || isNaN(Number(e))) { setMaxYear(null); @@ -163,7 +186,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte setMaxYear(null); } }, - [setMaxYear], + [isYearDisabled, setMaxYear], ); const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300); @@ -203,26 +226,32 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte value: 'multi', }, ]} + disabled={isArtistDisabled} onChange={handleArtistSelectModeChange} size="xs" value={artistSelectMode} /> ); - }, [artistSelectMode, handleArtistSelectModeChange, t]); + }, [artistSelectMode, handleArtistSelectModeChange, isArtistDisabled, t]); return ( {toggleFilters.map((filter) => ( {filter.label} - + ))} {!disableArtistFilter && ( <> 0)} + disabled={isYearDisabled} hideControls={false} label={t('filter.fromYear', { postProcess: 'sentenceCase' })} max={5000} @@ -261,7 +291,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte value={query.minYear ?? undefined} /> 0)} + disabled={isYearDisabled} hideControls={false} label={t('filter.toYear', { postProcess: 'sentenceCase' })} max={5000} diff --git a/src/renderer/features/shared/components/multi-select-rows.module.css b/src/renderer/features/shared/components/multi-select-rows.module.css index 05e8642f3..86ee79013 100644 --- a/src/renderer/features/shared/components/multi-select-rows.module.css +++ b/src/renderer/features/shared/components/multi-select-rows.module.css @@ -27,3 +27,13 @@ .row[data-focused='true'] { border: 1px solid var(--theme-colors-primary); } + +.row.disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.row.disabled:hover { + cursor: not-allowed; + background-color: transparent; +} diff --git a/src/renderer/features/shared/components/multi-select-rows.tsx b/src/renderer/features/shared/components/multi-select-rows.tsx index 48d9ff322..84004b7d7 100644 --- a/src/renderer/features/shared/components/multi-select-rows.tsx +++ b/src/renderer/features/shared/components/multi-select-rows.tsx @@ -11,6 +11,7 @@ import { Text } from '/@/shared/components/text/text'; import { LibraryItem } from '/@/shared/types/domain-types'; export function ArtistMultiSelectRow({ + disabled = false, displayCountType = 'album', focusedIndex, index, @@ -18,6 +19,7 @@ export function ArtistMultiSelectRow({ options, style, }: RowComponentProps<{ + disabled?: boolean; displayCountType?: 'album' | 'song'; focusedIndex: null | number; onToggle: (value: string) => void; @@ -41,11 +43,11 @@ export function ArtistMultiSelectRow({ return ( void; @@ -99,11 +103,11 @@ export function GenreMultiSelectRow({ return (
diff --git a/src/renderer/features/songs/components/subsonic-song-filters.tsx b/src/renderer/features/songs/components/subsonic-song-filters.tsx index 7bca28087..5d557190d 100644 --- a/src/renderer/features/songs/components/subsonic-song-filters.tsx +++ b/src/renderer/features/songs/components/subsonic-song-filters.tsx @@ -1,10 +1,17 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; import { ChangeEvent, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { useListContext } from '/@/renderer/context/list-context'; +import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; -import { GenreMultiSelectRow } from '/@/renderer/features/shared/components/multi-select-rows'; +import { + ArtistMultiSelectRow, + GenreMultiSelectRow, +} from '/@/renderer/features/shared/components/multi-select-rows'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; +import { useCurrentServerId } from '/@/renderer/store'; import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; @@ -12,10 +19,12 @@ import { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-mu import { Stack } from '/@/shared/components/stack/stack'; import { Switch } from '/@/shared/components/switch/switch'; import { Text } from '/@/shared/components/text/text'; +import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types'; export const SubsonicSongFilters = () => { const { t } = useTranslation(); - const { clear, query, setFavorite, setGenreId } = useSongListFilters(); + const serverId = useCurrentServerId(); + const { clear, query, setArtistIds, setFavorite, setGenreId } = useSongListFilters(); const { customFilters } = useListContext(); @@ -35,15 +44,75 @@ export const SubsonicSongFilters = () => { const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]); + const albumArtistListQuery = useSuspenseQuery( + artistsQueries.albumArtistList({ + options: { + gcTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 1, + }, + query: { + sortBy: AlbumArtistListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId, + }), + ); + + const items = albumArtistListQuery?.data?.items; + + const selectableAlbumArtists = useMemo(() => { + if (!items) return []; + + return items.map((artist) => ({ + albumCount: artist.albumCount, + imageUrl: getItemImageUrl({ + id: artist.id, + itemType: LibraryItem.ARTIST, + type: 'table', + }), + label: artist.name, + songCount: artist.songCount, + value: artist.id, + })); + }, [items]); + + const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]); + + const hasFavorite = query.favorite === true; + const hasArtist = query.artistIds && query.artistIds.length > 0; + const hasGenre = query.genreIds && query.genreIds.length > 0; + + const isFavoriteDisabled = hasArtist || hasGenre; + const isArtistDisabled = hasFavorite || hasGenre; + const isGenreDisabled = hasFavorite || hasArtist; + + const handleArtistFilter = useCallback( + (e: null | string[]) => { + if (isArtistDisabled && e !== null) return; + setArtistIds(e ?? null); + }, + [isArtistDisabled, setArtistIds], + ); + + const artistFilterLabel = useMemo(() => { + return ( + + {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })} + + ); + }, [t]); + const handleGenresFilter = useCallback( (e: null | string[]) => { + if (isGenreDisabled && e !== null && e.length > 0) return; if (e && e.length > 0) { setGenreId([e[0]]); } else { setGenreId(null); } }, - [setGenreId], + [isGenreDisabled, setGenreId], ); const genreFilterLabel = useMemo(() => { @@ -59,13 +128,14 @@ export const SubsonicSongFilters = () => { { label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), onChange: (e: ChangeEvent) => { + if (isFavoriteDisabled && e.target.checked) return; const favoriteValue = e.target.checked ? true : undefined; setFavorite(favoriteValue ?? null); }, value: query.favorite, }, ], - [t, query.favorite, setFavorite], + [isFavoriteDisabled, query.favorite, setFavorite, t], ); return ( @@ -73,13 +143,30 @@ export const SubsonicSongFilters = () => { {toggleFilters.map((filter) => ( {filter.label} - + ))} + + {!isGenrePage && ( <> = T & { label: string; value: string }; interface VirtualMultiSelectProps { + disabled?: boolean; displayCountType?: 'album' | 'song'; height: number; isLoading?: boolean; @@ -25,6 +26,7 @@ interface VirtualMultiSelectProps { options: VirtualMultiSelectOption[]; RowComponent: ( props: RowComponentProps<{ + disabled?: boolean; displayCountType?: 'album' | 'song'; focusedIndex: null | number; onToggle: (value: string) => void; @@ -37,6 +39,7 @@ interface VirtualMultiSelectProps { } export function VirtualMultiSelect({ + disabled = false, displayCountType = 'album', height, isLoading = false, @@ -105,6 +108,7 @@ export function VirtualMultiSelect({ const handleToggle = useCallback( (optionValue: string) => { + if (disabled) return; if (value.includes(optionValue)) { const newValue = value.filter((v) => v !== optionValue); onChange(newValue.length > 0 ? newValue : null); @@ -112,15 +116,16 @@ export function VirtualMultiSelect({ onChange(singleSelect ? [optionValue] : [...value, optionValue]); } }, - [onChange, singleSelect, value], + [disabled, onChange, singleSelect, value], ); const handleDeselect = useCallback( (optionValue: string) => { + if (disabled) return; const newValue = value.filter((v) => v !== optionValue); onChange(newValue.length > 0 ? newValue : null); }, - [onChange, value], + [disabled, onChange, value], ); const placeholder = useMemo( @@ -147,7 +152,7 @@ export function VirtualMultiSelect({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (stableOptions.length === 0) return; + if (disabled || stableOptions.length === 0) return; switch (e.key) { case ' ': @@ -186,16 +191,17 @@ export function VirtualMultiSelect({ break; } }, - [focusedIndex, handleToggle, scrollToIndex, stableOptions], + [disabled, focusedIndex, handleToggle, scrollToIndex, stableOptions], ); return ( -
+
0 ? ( + value.length > 0 && !disabled ? ( ({ /> ) : undefined } - onChange={(e) => setSearch(e.currentTarget.value)} + onChange={(e) => { + if (!disabled) { + setSearch(e.currentTarget.value); + } + }} placeholder={placeholder} rightSection={ - {search ? ( + {search && !disabled ? ( ({ )} } - styles={{ label: { width: '100%' } }} + styles={{ + input: disabled ? { opacity: 0.6 } : undefined, + label: { width: '100%' }, + section: disabled ? { opacity: 0.6 } : undefined, + wrapper: disabled ? { opacity: 0.6 } : undefined, + }} value={search} />
{ + if (disabled) return; const element = e.currentTarget as HTMLDivElement; if (element.focus) { element.focus({ preventScroll: true }); @@ -239,7 +255,7 @@ export function VirtualMultiSelect({ }} ref={listContainerRef} style={{ height: `${height}px` }} - tabIndex={0} + tabIndex={disabled ? -1 : 0} > {isLoading ? (
@@ -258,6 +274,7 @@ export function VirtualMultiSelect({ rowCount={stableOptions.length} rowHeight={rowHeight} rowProps={{ + disabled, displayCountType, focusedIndex, onToggle: handleToggle, @@ -271,19 +288,21 @@ export function VirtualMultiSelect({ {selectedOptions.map((option) => ( handleDeselect(option.value)} wrap="nowrap" > - + {!disabled && ( + + )} {option.label}