mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
add player filters to omit songs from queue based on criteria
This commit is contained in:
@@ -95,6 +95,7 @@
|
|||||||
"no": "no",
|
"no": "no",
|
||||||
"none": "none",
|
"none": "none",
|
||||||
"noResultsFromQuery": "the query returned no results",
|
"noResultsFromQuery": "the query returned no results",
|
||||||
|
"noFilters": "no filters configured",
|
||||||
"note": "note",
|
"note": "note",
|
||||||
"ok": "ok",
|
"ok": "ok",
|
||||||
"owner": "owner",
|
"owner": "owner",
|
||||||
@@ -252,6 +253,27 @@
|
|||||||
"trackNumber": "track",
|
"trackNumber": "track",
|
||||||
"explicitStatus": "$t(common.explicitStatus)"
|
"explicitStatus": "$t(common.explicitStatus)"
|
||||||
},
|
},
|
||||||
|
"filterOperator": {
|
||||||
|
"after": "is after",
|
||||||
|
"afterDate": "is after (date)",
|
||||||
|
"before": "is before",
|
||||||
|
"beforeDate": "is before (date)",
|
||||||
|
"contains": "contains",
|
||||||
|
"endsWith": "ends with",
|
||||||
|
"inPlaylist": "is in",
|
||||||
|
"inTheLast": "is in the last",
|
||||||
|
"inTheRange": "is in the range",
|
||||||
|
"inTheRangeDate": "is in the range (date)",
|
||||||
|
"is": "is",
|
||||||
|
"isNot": "is not",
|
||||||
|
"isGreaterThan": "is greater than",
|
||||||
|
"isLessThan": "is less than",
|
||||||
|
"matchesRegex": "matches regex",
|
||||||
|
"notContains": "does not contain",
|
||||||
|
"notInPlaylist": "is not in",
|
||||||
|
"notInTheLast": "is not in the last",
|
||||||
|
"startsWith": "starts with"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"addServer": {
|
"addServer": {
|
||||||
"error_savePassword": "an error occurred when trying to save the password",
|
"error_savePassword": "an error occurred when trying to save the password",
|
||||||
@@ -502,7 +524,8 @@
|
|||||||
"audio": "audio",
|
"audio": "audio",
|
||||||
"lyrics": "lyrics",
|
"lyrics": "lyrics",
|
||||||
"transcoding": "transcoding",
|
"transcoding": "transcoding",
|
||||||
"discord": "discord"
|
"discord": "discord",
|
||||||
|
"playerFilters": "player filters"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"albumArtists": "$t(entity.albumArtist_other)",
|
"albumArtists": "$t(entity.albumArtist_other)",
|
||||||
@@ -768,6 +791,8 @@
|
|||||||
"notify_description": "show notifications when changing the current song",
|
"notify_description": "show notifications when changing the current song",
|
||||||
"passwordStore_description": "what password/secret store to use. change this if you are having issues storing passwords",
|
"passwordStore_description": "what password/secret store to use. change this if you are having issues storing passwords",
|
||||||
"passwordStore": "passwords/secret store",
|
"passwordStore": "passwords/secret store",
|
||||||
|
"playerFilters": "Filter songs from the queue",
|
||||||
|
"playerFilters_description": "omit songs from being added to the queue based on the following criteria",
|
||||||
"playbackStyle_description": "select the playback style to use for the audio player",
|
"playbackStyle_description": "select the playback style to use for the audio player",
|
||||||
"playbackStyle_optionCrossFade": "crossfade",
|
"playbackStyle_optionCrossFade": "crossfade",
|
||||||
"playbackStyle_optionNormal": "normal",
|
"playbackStyle_optionNormal": "normal",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
|
|||||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||||
import {
|
import {
|
||||||
|
filterSongsByPlayerFilters,
|
||||||
getAlbumArtistSongsById,
|
getAlbumArtistSongsById,
|
||||||
getAlbumSongsById,
|
getAlbumSongsById,
|
||||||
getGenreSongsById,
|
getGenreSongsById,
|
||||||
@@ -19,7 +20,7 @@ import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-
|
|||||||
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||||||
import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
|
import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
|
||||||
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
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 { LogCategory, logFn } from '/@/renderer/utils/logger';
|
||||||
import { logMsg } from '/@/renderer/utils/logger-message';
|
import { logMsg } from '/@/renderer/utils/logger-message';
|
||||||
import { sortSongsByFetchedOrder } from '/@/shared/api/utils';
|
import { sortSongsByFetchedOrder } from '/@/shared/api/utils';
|
||||||
@@ -209,22 +210,31 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
|
|
||||||
const addToQueueByData = useCallback(
|
const addToQueueByData = useCallback(
|
||||||
(data: Song[], type: AddToQueueType, playSongId?: string) => {
|
(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) {
|
if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
|
||||||
const edge = type.edge === 'top' ? 'top' : 'bottom';
|
const edge = type.edge === 'top' ? 'top' : 'bottom';
|
||||||
|
|
||||||
logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByData, {
|
logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByData, {
|
||||||
category: LogCategory.PLAYER,
|
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 {
|
} else {
|
||||||
logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByType, {
|
logFn.debug(logMsg[LogCategory.PLAYER].addToQueueByType, {
|
||||||
category: LogCategory.PLAYER,
|
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],
|
[storeActions],
|
||||||
@@ -295,11 +305,14 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
|
|
||||||
const sortedSongs = sortSongsByFetchedOrder(songs, id, itemType);
|
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) {
|
if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
|
||||||
const edge = type.edge === 'top' ? 'top' : 'bottom';
|
const edge = type.edge === 'top' ? 'top' : 'bottom';
|
||||||
storeActions.addToQueueByUniqueId(sortedSongs, type.uniqueId, edge);
|
storeActions.addToQueueByUniqueId(filteredSongs, type.uniqueId, edge);
|
||||||
} else {
|
} else {
|
||||||
storeActions.addToQueueByType(sortedSongs, type as Play);
|
storeActions.addToQueueByType(filteredSongs, type as Play);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (instanceOfCancellationError(err)) {
|
if (instanceOfCancellationError(err)) {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { QueryClient } from '@tanstack/react-query';
|
|||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { folderQueries } from '/@/renderer/features/folders/api/folder-api';
|
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 { sortSongList } from '/@/shared/api/utils';
|
||||||
import {
|
import {
|
||||||
PlaylistSongListQuery,
|
PlaylistSongListQuery,
|
||||||
@@ -330,3 +333,131 @@ export const getSongById = async (args: {
|
|||||||
totalRecordCount: 1,
|
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 { lazy, Suspense, useMemo } from 'react';
|
||||||
|
|
||||||
import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings';
|
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 { TranscodeSettings } from '/@/renderer/features/settings/components/playback/transcode-settings';
|
||||||
import { useSettingsStore } from '/@/renderer/store';
|
import { useSettingsStore } from '/@/renderer/store';
|
||||||
import { Divider } from '/@/shared/components/divider/divider';
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
@@ -31,6 +32,8 @@ export const PlaybackTab = () => {
|
|||||||
<Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense>
|
<Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense>
|
||||||
<Divider />
|
<Divider />
|
||||||
<TranscodeSettings />
|
<TranscodeSettings />
|
||||||
|
<Divider />
|
||||||
|
<PlayerFilterSettings />
|
||||||
</Stack>
|
</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' })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -304,9 +304,56 @@ const ScrobbleSettingsSchema = z.object({
|
|||||||
scrobbleAtPercentage: z.number(),
|
scrobbleAtPercentage: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PlayerFilterFieldSchema = z.enum([
|
||||||
|
'name',
|
||||||
|
'albumArtist',
|
||||||
|
'artist',
|
||||||
|
'duration',
|
||||||
|
'genre',
|
||||||
|
'year',
|
||||||
|
'note',
|
||||||
|
'path',
|
||||||
|
'playCount',
|
||||||
|
'favorite',
|
||||||
|
'rating',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PlayerFilterOperatorSchema = z.enum([
|
||||||
|
'is',
|
||||||
|
'isNot',
|
||||||
|
'contains',
|
||||||
|
'notContains',
|
||||||
|
'startsWith',
|
||||||
|
'endsWith',
|
||||||
|
'regex',
|
||||||
|
'gt',
|
||||||
|
'lt',
|
||||||
|
'inTheRange',
|
||||||
|
'before',
|
||||||
|
'after',
|
||||||
|
'beforeDate',
|
||||||
|
'afterDate',
|
||||||
|
'inTheRangeDate',
|
||||||
|
'inTheLast',
|
||||||
|
'notInTheLast',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PlayerFilterSchema = z.object({
|
||||||
|
field: PlayerFilterFieldSchema,
|
||||||
|
id: z.string(),
|
||||||
|
operator: PlayerFilterOperatorSchema,
|
||||||
|
value: z.union([
|
||||||
|
z.string(),
|
||||||
|
z.number(),
|
||||||
|
z.boolean(),
|
||||||
|
z.array(z.union([z.string(), z.number()])),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
const PlaybackSettingsSchema = z.object({
|
const PlaybackSettingsSchema = z.object({
|
||||||
audioDeviceId: z.string().nullable().optional(),
|
audioDeviceId: z.string().nullable().optional(),
|
||||||
audioFadeOnStatusChange: z.boolean(),
|
audioFadeOnStatusChange: z.boolean(),
|
||||||
|
filters: z.array(PlayerFilterSchema),
|
||||||
mediaSession: z.boolean(),
|
mediaSession: z.boolean(),
|
||||||
mpvExtraParameters: z.array(z.string()),
|
mpvExtraParameters: z.array(z.string()),
|
||||||
mpvProperties: MpvSettingsSchema,
|
mpvProperties: MpvSettingsSchema,
|
||||||
@@ -486,6 +533,12 @@ export type ItemListSettings = {
|
|||||||
table: DataTableProps;
|
table: DataTableProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlayerFilter = z.infer<typeof PlayerFilterSchema>;
|
||||||
|
|
||||||
|
export type PlayerFilterField = z.infer<typeof PlayerFilterFieldSchema>;
|
||||||
|
|
||||||
|
export type PlayerFilterOperator = z.infer<typeof PlayerFilterOperatorSchema>;
|
||||||
|
|
||||||
export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
|
export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
|
||||||
actions: {
|
actions: {
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
@@ -494,6 +547,7 @@ export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
|
|||||||
setGenreBehavior: (target: GenreTarget) => void;
|
setGenreBehavior: (target: GenreTarget) => void;
|
||||||
setHomeItems: (item: SortableItem<HomeItem>[]) => void;
|
setHomeItems: (item: SortableItem<HomeItem>[]) => void;
|
||||||
setList: (type: ItemListKey, data: DeepPartial<ItemListSettings>) => void;
|
setList: (type: ItemListKey, data: DeepPartial<ItemListSettings>) => void;
|
||||||
|
setPlaybackFilters: (filters: PlayerFilter[]) => void;
|
||||||
setSettings: (data: Partial<SettingsState>) => void;
|
setSettings: (data: Partial<SettingsState>) => void;
|
||||||
setSidebarItems: (items: SidebarItemType[]) => void;
|
setSidebarItems: (items: SidebarItemType[]) => void;
|
||||||
setTable: (type: ItemListKey, data: DataTableProps) => void;
|
setTable: (type: ItemListKey, data: DataTableProps) => void;
|
||||||
@@ -502,9 +556,7 @@ export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
|
|||||||
toggleSidebarCollapseShare: () => void;
|
toggleSidebarCollapseShare: () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsState extends z.infer<typeof SettingsStateSchema> {}
|
export interface SettingsState extends z.infer<typeof SettingsStateSchema> {}
|
||||||
|
|
||||||
export type SidebarItemType = z.infer<typeof SidebarItemTypeSchema>;
|
export type SidebarItemType = z.infer<typeof SidebarItemTypeSchema>;
|
||||||
|
|
||||||
export type SideQueueType = z.infer<typeof SideQueueTypeSchema>;
|
export type SideQueueType = z.infer<typeof SideQueueTypeSchema>;
|
||||||
@@ -1149,6 +1201,7 @@ const initialState: SettingsState = {
|
|||||||
playback: {
|
playback: {
|
||||||
audioDeviceId: undefined,
|
audioDeviceId: undefined,
|
||||||
audioFadeOnStatusChange: true,
|
audioFadeOnStatusChange: true,
|
||||||
|
filters: [],
|
||||||
mediaSession: false,
|
mediaSession: false,
|
||||||
mpvExtraParameters: [],
|
mpvExtraParameters: [],
|
||||||
mpvProperties: {
|
mpvProperties: {
|
||||||
@@ -1253,6 +1306,11 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
setPlaybackFilters: (filters: PlayerFilter[]) => {
|
||||||
|
set((state) => {
|
||||||
|
state.playback.filters = filters;
|
||||||
|
});
|
||||||
|
},
|
||||||
setSettings: (data) => {
|
setSettings: (data) => {
|
||||||
set({ ...get(), ...data });
|
set({ ...get(), ...data });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const logMsg = {
|
|||||||
moveSelectedToNext: 'Moved selected to next',
|
moveSelectedToNext: 'Moved selected to next',
|
||||||
moveSelectedToTop: 'Moved selected to top',
|
moveSelectedToTop: 'Moved selected to top',
|
||||||
playbackError: 'An error occurred during playback',
|
playbackError: 'An error occurred during playback',
|
||||||
|
playerFiltersApplied: 'Player filters applied',
|
||||||
setFavorite: 'Set favorite',
|
setFavorite: 'Set favorite',
|
||||||
setRating: 'Set rating',
|
setRating: 'Set rating',
|
||||||
setRepeat: 'Set repeat',
|
setRepeat: 'Set repeat',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export enum NDAlbumArtistListSort {
|
export enum NDAlbumArtistListSort {
|
||||||
@@ -180,45 +181,126 @@ export const NDSongQueryFields = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const NDSongQueryPlaylistOperators = [
|
export const NDSongQueryPlaylistOperators = [
|
||||||
{ label: 'is in', value: 'inPlaylist' },
|
{
|
||||||
{ label: 'is not in', value: 'notInPlaylist' },
|
label: i18n.t('filterOperator.inPlaylist', { postProcess: 'titleCase' }),
|
||||||
|
value: 'inPlaylist',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.notInPlaylist', { postProcess: 'titleCase' }),
|
||||||
|
value: 'notInPlaylist',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NDSongQueryDateOperators = [
|
export const NDSongQueryDateOperators = [
|
||||||
{ label: 'is', value: 'is' },
|
{
|
||||||
{ label: 'is not', value: 'isNot' },
|
label: i18n.t('filterOperator.is', { postProcess: 'titleCase' }),
|
||||||
{ label: 'is before', value: 'before' },
|
value: 'is',
|
||||||
{ label: 'is after', value: 'after' },
|
},
|
||||||
{ label: 'is in the last', value: 'inTheLast' },
|
{
|
||||||
{ label: 'is not in the last', value: 'notInTheLast' },
|
label: i18n.t('filterOperator.isNot', { postProcess: 'titleCase' }),
|
||||||
{ label: 'is in the range', value: 'inTheRange' },
|
value: 'isNot',
|
||||||
{ label: 'is before (date)', value: 'beforeDate' },
|
},
|
||||||
{ label: 'is after (date)', value: 'afterDate' },
|
{
|
||||||
{ label: 'is in the range (date)', value: 'inTheRangeDate' },
|
label: i18n.t('filterOperator.before', { postProcess: 'titleCase' }),
|
||||||
|
value: 'before',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.after', { postProcess: 'titleCase' }),
|
||||||
|
value: 'after',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.inTheLast', { postProcess: 'titleCase' }),
|
||||||
|
value: 'inTheLast',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.notInTheLast', { postProcess: 'titleCase' }),
|
||||||
|
value: 'notInTheLast',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.inTheRange', { postProcess: 'titleCase' }),
|
||||||
|
value: 'inTheRange',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.beforeDate', { postProcess: 'titleCase' }),
|
||||||
|
value: 'beforeDate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.afterDate', { postProcess: 'titleCase' }),
|
||||||
|
value: 'afterDate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.inTheRangeDate', { postProcess: 'titleCase' }),
|
||||||
|
value: 'inTheRangeDate',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NDSongQueryStringOperators = [
|
export const NDSongQueryStringOperators = [
|
||||||
{ label: 'is', value: 'is' },
|
{
|
||||||
{ label: 'is not', value: 'isNot' },
|
label: i18n.t('filterOperator.is', { postProcess: 'titleCase' }),
|
||||||
{ label: 'contains', value: 'contains' },
|
value: 'is',
|
||||||
{ label: 'does not contain', value: 'notContains' },
|
},
|
||||||
{ label: 'starts with', value: 'startsWith' },
|
{
|
||||||
{ label: 'ends with', value: 'endsWith' },
|
label: i18n.t('filterOperator.isNot', { postProcess: 'titleCase' }),
|
||||||
|
value: 'isNot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.contains', { postProcess: 'titleCase' }),
|
||||||
|
value: 'contains',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.notContains', { postProcess: 'titleCase' }),
|
||||||
|
value: 'notContains',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.startsWith', { postProcess: 'titleCase' }),
|
||||||
|
value: 'startsWith',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.endsWith', { postProcess: 'titleCase' }),
|
||||||
|
value: 'endsWith',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NDSongQueryBooleanOperators = [
|
export const NDSongQueryBooleanOperators = [
|
||||||
{ label: 'is', value: 'is' },
|
{
|
||||||
{ label: 'is not', value: 'isNot' },
|
label: i18n.t('filterOperator.is', { postProcess: 'titleCase' }),
|
||||||
|
value: 'is',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.isNot', { postProcess: 'titleCase' }),
|
||||||
|
value: 'isNot',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NDSongQueryNumberOperators = [
|
export const NDSongQueryNumberOperators = [
|
||||||
{ label: 'is', value: 'is' },
|
{
|
||||||
{ label: 'is not', value: 'isNot' },
|
label: i18n.t('filterOperator.is', { postProcess: 'titleCase' }),
|
||||||
{ label: 'contains', value: 'contains' },
|
value: 'is',
|
||||||
{ label: 'does not contain', value: 'notContains' },
|
},
|
||||||
{ label: 'is greater than', value: 'gt' },
|
{
|
||||||
{ label: 'is less than', value: 'lt' },
|
label: i18n.t('filterOperator.isNot', { postProcess: 'titleCase' }),
|
||||||
{ label: 'is in the range', value: 'inTheRange' },
|
value: 'isNot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.contains', { postProcess: 'titleCase' }),
|
||||||
|
value: 'contains',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.notContains', { postProcess: 'titleCase' }),
|
||||||
|
value: 'notContains',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.isGreaterThan', { postProcess: 'titleCase' }),
|
||||||
|
value: 'gt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.isLessThan', { postProcess: 'titleCase' }),
|
||||||
|
value: 'lt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('filterOperator.inTheRange', { postProcess: 'titleCase' }),
|
||||||
|
value: 'inTheRange',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export enum NDUserListSort {
|
export enum NDUserListSort {
|
||||||
|
|||||||
Reference in New Issue
Block a user