add player filters to omit songs from queue based on criteria

This commit is contained in:
jeffvli
2025-12-03 22:11:18 -08:00
parent 5540ca4e32
commit ccdd16292a
8 changed files with 757 additions and 37 deletions
@@ -8,6 +8,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import {
filterSongsByPlayerFilters,
getAlbumArtistSongsById,
getAlbumSongsById,
getGenreSongsById,
@@ -19,7 +20,7 @@ import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AddToQueueType, usePlayerActions } from '/@/renderer/store';
import { AddToQueueType, usePlayerActions, useSettingsStore } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { sortSongsByFetchedOrder } from '/@/shared/api/utils';
@@ -209,22 +210,31 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const addToQueueByData = useCallback(
(data: Song[], type: AddToQueueType, playSongId?: string) => {
const filters = useSettingsStore.getState().playback.filters;
const filteredData = filterSongsByPlayerFilters(data, filters);
if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
const edge = type.edge === 'top' ? 'top' : 'bottom';
logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByData, {
category: LogCategory.PLAYER,
meta: { data: data.length, edge, type, uniqueId: type.uniqueId },
meta: {
data: data.length,
edge,
filtered: filteredData.length,
type,
uniqueId: type.uniqueId,
},
});
storeActions.addToQueueByUniqueId(data, type.uniqueId, edge, playSongId);
storeActions.addToQueueByUniqueId(filteredData, type.uniqueId, edge, playSongId);
} else {
logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByType, {
category: LogCategory.PLAYER,
meta: { data: data.length, type },
meta: { data: data.length, filtered: filteredData.length, type },
});
storeActions.addToQueueByType(data, type as Play, playSongId);
storeActions.addToQueueByType(filteredData, type as Play, playSongId);
}
},
[storeActions],
@@ -295,11 +305,14 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const sortedSongs = sortSongsByFetchedOrder(songs, id, itemType);
const filters = useSettingsStore.getState().playback.filters;
const filteredSongs = filterSongsByPlayerFilters(sortedSongs, filters);
if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
const edge = type.edge === 'top' ? 'top' : 'bottom';
storeActions.addToQueueByUniqueId(sortedSongs, type.uniqueId, edge);
storeActions.addToQueueByUniqueId(filteredSongs, type.uniqueId, edge);
} else {
storeActions.addToQueueByType(sortedSongs, type as Play);
storeActions.addToQueueByType(filteredSongs, type as Play);
}
} catch (err: any) {
if (instanceOfCancellationError(err)) {
+131
View File
@@ -3,6 +3,9 @@ import { QueryClient } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { folderQueries } from '/@/renderer/features/folders/api/folder-api';
import { PlayerFilter, useSettingsStore } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { sortSongList } from '/@/shared/api/utils';
import {
PlaylistSongListQuery,
@@ -330,3 +333,131 @@ export const getSongById = async (args: {
totalRecordCount: 1,
};
};
const getSongFieldValue = (song: Song, field: string): boolean | null | number | string => {
switch (field) {
case 'albumArtist':
return song.albumArtists[0]?.name || '';
case 'artist':
return song.artistName || song.artists[0]?.name || '';
case 'duration':
return song.duration;
case 'favorite':
return song.userFavorite;
case 'genre':
return song.genres[0]?.name || '';
case 'name':
return song.name;
case 'note':
return song.comment || '';
case 'path':
return song.path || '';
case 'playCount':
return song.playCount;
case 'rating':
return song.userRating || 0;
case 'year':
return song.releaseYear || 0;
default:
return null;
}
};
const matchesFilter = (song: Song, filter: PlayerFilter): boolean => {
const songValue = getSongFieldValue(song, filter.field);
const filterValue = filter.value;
// Handle null/undefined values
if (songValue === null || songValue === undefined) {
return false;
}
switch (filter.operator) {
case 'contains':
return String(songValue).toLowerCase().includes(String(filterValue).toLowerCase());
case 'endsWith':
return String(songValue).toLowerCase().endsWith(String(filterValue).toLowerCase());
case 'is':
return String(songValue).toLowerCase() === String(filterValue).toLowerCase();
case 'isNot':
return String(songValue).toLowerCase() !== String(filterValue).toLowerCase();
case 'lt':
return Number(songValue) < Number(filterValue);
case 'notContains':
return !String(songValue).toLowerCase().includes(String(filterValue).toLowerCase());
case 'regex': {
try {
const regex = new RegExp(String(filterValue), 'i');
return regex.test(String(songValue));
} catch {
// Invalid regex pattern, don't match
return false;
}
}
case 'gt':
return Number(songValue) > Number(filterValue);
case 'startsWith':
return String(songValue).toLowerCase().startsWith(String(filterValue).toLowerCase());
default:
return true;
}
};
export const filterSongsByPlayerFilters = (songs: Song[], filters: PlayerFilter[]): Song[] => {
// Filter out invalid filters (missing field, operator, or value)
const validFilters = filters.filter(
(filter) =>
filter.field &&
filter.operator &&
filter.value !== undefined &&
filter.value !== null &&
filter.value !== '',
);
// If no valid filters, return all songs
if (validFilters.length === 0) {
return songs;
}
// Track filtered songs and their matching conditions
const filteredSongs: Array<{ filter: PlayerFilter; song: Song }> = [];
// Filter OUT songs that match any of the filters (exclude matching songs)
const filtered = songs.filter((song) => {
const matchingFilter = validFilters.find((filter) => matchesFilter(song, filter));
if (matchingFilter) {
filteredSongs.push({ filter: matchingFilter, song });
return false;
}
return true;
});
if (filteredSongs.length > 0) {
logFn.debug(logMsg[LogCategory.PLAYER].playerFiltersApplied, {
category: LogCategory.PLAYER,
meta: {
filteredCount: filteredSongs.length,
filteredSongs: filteredSongs.map(({ filter, song }) => ({
artist: song.artistName,
condition: {
field: filter.field,
operator: filter.operator,
value: filter.value,
},
songId: song.id,
songName: song.name,
})),
originalCount: songs.length,
remainingCount: filtered.length,
},
});
}
return filtered;
};
export const getPlayerFiltersAndFilterSongs = (songs: Song[]): Song[] => {
const state = useSettingsStore.getState();
const filters = state.playback.filters;
return filterSongsByPlayerFilters(songs, filters);
};
@@ -2,6 +2,7 @@ import isElectron from 'is-electron';
import { lazy, Suspense, useMemo } from 'react';
import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings';
import { PlayerFilterSettings } from '/@/renderer/features/settings/components/playback/player-filter-settings';
import { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings';
import { useSettingsStore } from '/@/renderer/store';
import { Divider } from '/@/shared/components/divider/divider';
@@ -31,6 +32,8 @@ export const PlaybackTab = () => {
<Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense>
<Divider />
<TranscodeSettings />
<Divider />
<PlayerFilterSettings />
</Stack>
);
};
@@ -0,0 +1,407 @@
import { nanoid } from 'nanoid/non-secure';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import {
PlayerFilter,
PlayerFilterField,
PlayerFilterOperator,
useSettingsStore,
useSettingsStoreActions,
} from '/@/renderer/store';
import {
NDSongQueryBooleanOperators,
NDSongQueryDateOperators,
NDSongQueryNumberOperators,
NDSongQueryStringOperators,
} from '/@/shared/api/navidrome/navidrome-types';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { DateInput } from '/@/shared/components/date-picker/date-picker';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
type FilterFieldConfig = {
label: string;
type: 'boolean' | 'date' | 'number' | 'string';
value: PlayerFilterField;
};
const getFilterFields = (t: (key: string, options?: any) => string): FilterFieldConfig[] => [
{
label: t('table.config.label.title', { postProcess: 'titleCase' }),
type: 'string',
value: 'name',
},
{
label: t('table.config.label.albumArtist', { postProcess: 'titleCase' }),
type: 'string',
value: 'albumArtist',
},
{
label: t('table.config.label.artist', { postProcess: 'titleCase' }),
type: 'string',
value: 'artist',
},
{
label: t('table.config.label.duration', { postProcess: 'titleCase' }),
type: 'number',
value: 'duration',
},
{
label: t('table.config.label.genre', { postProcess: 'titleCase' }),
type: 'string',
value: 'genre',
},
{
label: t('table.config.label.year', { postProcess: 'titleCase' }),
type: 'number',
value: 'year',
},
{
label: t('table.config.label.note', { postProcess: 'titleCase' }),
type: 'string',
value: 'note',
},
{
label: t('table.config.label.path', { postProcess: 'titleCase' }),
type: 'string',
value: 'path',
},
{
label: t('table.config.label.playCount', { postProcess: 'titleCase' }),
type: 'number',
value: 'playCount',
},
{
label: t('table.config.label.favorite', { postProcess: 'titleCase' }),
type: 'boolean',
value: 'favorite',
},
{
label: t('table.config.label.rating', { postProcess: 'titleCase' }),
type: 'number',
value: 'rating',
},
];
const getOperatorsForFieldType = (
t: (key: string, options?: any) => string,
type: 'boolean' | 'date' | 'number' | 'string',
): { label: string; value: PlayerFilterOperator }[] => {
const translateOperator = (operator: PlayerFilterOperator): string => {
const operatorKeyMap: Record<PlayerFilterOperator, string> = {
after: 'filterOperator.after',
afterDate: 'filterOperator.afterDate',
before: 'filterOperator.before',
beforeDate: 'filterOperator.beforeDate',
contains: 'filterOperator.contains',
endsWith: 'filterOperator.endsWith',
gt: 'filterOperator.isGreaterThan',
inTheLast: 'filterOperator.inTheLast',
inTheRange: 'filterOperator.inTheRange',
inTheRangeDate: 'filterOperator.inTheRangeDate',
is: 'filterOperator.is',
isNot: 'filterOperator.isNot',
lt: 'filterOperator.isLessThan',
notContains: 'filterOperator.notContains',
notInTheLast: 'filterOperator.notInTheLast',
regex: 'filterOperator.matchesRegex',
startsWith: 'filterOperator.startsWith',
};
return t(operatorKeyMap[operator] || operator, { postProcess: 'titleCase' });
};
switch (type) {
case 'boolean': {
return (
NDSongQueryBooleanOperators as { label: string; value: PlayerFilterOperator }[]
).map((op) => ({
label: translateOperator(op.value),
value: op.value,
}));
}
case 'date': {
return (
NDSongQueryDateOperators as { label: string; value: PlayerFilterOperator }[]
).map((op) => ({
label: translateOperator(op.value),
value: op.value,
}));
}
case 'number': {
const numberOperators = (
NDSongQueryNumberOperators as {
label: string;
value: PlayerFilterOperator;
}[]
).filter((op) => op.value !== 'inTheRange');
return numberOperators.map((op) => ({
label: translateOperator(op.value),
value: op.value,
}));
}
case 'string': {
const stringOperators = [
...(NDSongQueryStringOperators as { label: string; value: PlayerFilterOperator }[]),
{ label: 'matches regex', value: 'regex' as PlayerFilterOperator },
];
return stringOperators.map((op) => ({
label: translateOperator(op.value),
value: op.value,
}));
}
default:
return [];
}
};
const FilterValueInput = ({
field,
filterFields,
onChange,
operator,
value,
}: {
field: PlayerFilterField;
filterFields: FilterFieldConfig[];
onChange: (value: (number | string)[] | boolean | number | string) => void;
operator: PlayerFilterOperator;
value: (number | string)[] | boolean | number | string | undefined;
}) => {
const fieldConfig = filterFields.find((f) => f.value === field);
const fieldType = fieldConfig?.type || 'string';
// Parse date value helper
const parseDateValue = (val: any): Date | null => {
if (!val) return null;
if (val instanceof Date) return val;
if (typeof val === 'string') {
const parsed = new Date(val);
if (isNaN(parsed.getTime())) return null;
return parsed;
}
return null;
};
const isDatePickerOperator =
operator === 'beforeDate' || operator === 'afterDate' || operator === 'inTheRangeDate';
switch (fieldType) {
case 'boolean':
return (
<Select
data={[
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' },
]}
onChange={(e) => onChange(e === 'true')}
value={value?.toString() || 'false'}
width="30%"
/>
);
case 'date':
if (isDatePickerOperator && operator !== 'inTheRangeDate') {
const dateValue = value ? parseDateValue(value) : null;
return (
<DateInput
clearable
defaultLevel="year"
maxWidth={170}
onChange={(date) => onChange(date || '')}
size="sm"
value={dateValue}
valueFormat="YYYY-MM-DD"
width="30%"
/>
);
}
return (
<TextInput
onChange={(e) => onChange(e.currentTarget.value)}
size="sm"
value={(value as string) || ''}
width="30%"
/>
);
case 'number':
return (
<NumberInput
onChange={(e) => onChange(Number(e) || 0)}
size="sm"
value={value !== undefined && value !== null ? Number(value) : undefined}
width="30%"
/>
);
case 'string':
default:
return (
<TextInput
onChange={(e) => onChange(e.currentTarget.value)}
size="sm"
value={(value as string) || ''}
width="30%"
/>
);
}
};
export const PlayerFilterSettings = () => {
const { t } = useTranslation();
const filters = useSettingsStore((state) => state.playback.filters);
const { setPlaybackFilters } = useSettingsStoreActions();
const filterFields = useMemo(() => getFilterFields(t), [t]);
const handleAddFilter = useCallback(() => {
const newFilter: PlayerFilter = {
field: 'name',
id: nanoid(),
operator: 'is',
value: '',
};
setPlaybackFilters([...filters, newFilter]);
}, [filters, setPlaybackFilters]);
const handleRemoveFilter = useCallback(
(id: string) => {
setPlaybackFilters(filters.filter((f) => f.id !== id));
},
[filters, setPlaybackFilters],
);
const handleFieldChange = useCallback(
(id: string, field: PlayerFilterField) => {
const fieldConfig = filterFields.find((f) => f.value === field);
const defaultOperator = getOperatorsForFieldType(t, fieldConfig?.type || 'string')[0]
.value;
const defaultValue =
fieldConfig?.type === 'boolean'
? false
: fieldConfig?.type === 'number'
? 0
: fieldConfig?.type === 'date'
? ''
: '';
setPlaybackFilters(
filters.map((f) =>
f.id === id
? {
...f,
field,
operator: defaultOperator,
value: defaultValue,
}
: f,
),
);
},
[filterFields, filters, setPlaybackFilters, t],
);
const handleOperatorChange = useCallback(
(id: string, operator: PlayerFilterOperator) => {
setPlaybackFilters(filters.map((f) => (f.id === id ? { ...f, operator } : f)));
},
[filters, setPlaybackFilters],
);
const handleValueChange = useCallback(
(id: string, value: (number | string)[] | boolean | number | string) => {
setPlaybackFilters(filters.map((f) => (f.id === id ? { ...f, value } : f)));
},
[filters, setPlaybackFilters],
);
const fieldOptions = useMemo(
() => filterFields.map((f) => ({ label: f.label, value: f.value })),
[filterFields],
);
const filterOptions: SettingOption[] = [
{
control: (
<Stack gap="md">
{filters.length > 0 && (
<Stack gap="sm">
{filters.map((filter) => {
const fieldConfig = filterFields.find(
(f) => f.value === filter.field,
);
const operators = getOperatorsForFieldType(
t,
fieldConfig?.type || 'string',
);
return (
<Group gap="sm" key={filter.id}>
<Select
data={fieldOptions}
onChange={(e) =>
handleFieldChange(filter.id, e as PlayerFilterField)
}
value={filter.field}
width="25%"
/>
<Select
data={operators}
onChange={(e) =>
handleOperatorChange(
filter.id,
e as PlayerFilterOperator,
)
}
value={filter.operator}
width="25%"
/>
<FilterValueInput
field={filter.field}
filterFields={filterFields}
onChange={(value) =>
handleValueChange(filter.id, value)
}
operator={filter.operator}
value={filter.value}
/>
<ActionIcon
icon="remove"
onClick={() => handleRemoveFilter(filter.id)}
size="sm"
variant="subtle"
/>
</Group>
);
})}
</Stack>
)}
<Group grow>
<Button onClick={handleAddFilter} variant="filled">
{t('common.add', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>
),
description: t('setting.playerFilters', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.playerFilters', { postProcess: 'sentenceCase' }),
},
];
return (
<SettingsSection
options={filterOptions}
title={t('page.setting.playerFilters', { postProcess: 'sentenceCase' })}
/>
);
};