From ccdd16292a5ff30efa46b6ca61c4782115dbc989 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 3 Dec 2025 22:11:18 -0800 Subject: [PATCH] add player filters to omit songs from queue based on criteria --- src/i18n/locales/en.json | 27 +- .../player/context/player-context.tsx | 27 +- src/renderer/features/player/utils.ts | 131 ++++++ .../components/playback/playback-tab.tsx | 3 + .../playback/player-filter-settings.tsx | 407 ++++++++++++++++++ src/renderer/store/settings.store.ts | 62 ++- src/renderer/utils/logger-message.ts | 1 + src/shared/api/navidrome/navidrome-types.ts | 136 ++++-- 8 files changed, 757 insertions(+), 37 deletions(-) create mode 100644 src/renderer/features/settings/components/playback/player-filter-settings.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 209975317..1f262373e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -95,6 +95,7 @@ "no": "no", "none": "none", "noResultsFromQuery": "the query returned no results", + "noFilters": "no filters configured", "note": "note", "ok": "ok", "owner": "owner", @@ -252,6 +253,27 @@ "trackNumber": "track", "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": { "addServer": { "error_savePassword": "an error occurred when trying to save the password", @@ -502,7 +524,8 @@ "audio": "audio", "lyrics": "lyrics", "transcoding": "transcoding", - "discord": "discord" + "discord": "discord", + "playerFilters": "player filters" }, "sidebar": { "albumArtists": "$t(entity.albumArtist_other)", @@ -768,6 +791,8 @@ "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": "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_optionCrossFade": "crossfade", "playbackStyle_optionNormal": "normal", diff --git a/src/renderer/features/player/context/player-context.tsx b/src/renderer/features/player/context/player-context.tsx index 1c44d51a7..ff3858fb3 100644 --- a/src/renderer/features/player/context/player-context.tsx +++ b/src/renderer/features/player/context/player-context.tsx @@ -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)) { diff --git a/src/renderer/features/player/utils.ts b/src/renderer/features/player/utils.ts index 43a60680f..bd77fec2d 100644 --- a/src/renderer/features/player/utils.ts +++ b/src/renderer/features/player/utils.ts @@ -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); +}; diff --git a/src/renderer/features/settings/components/playback/playback-tab.tsx b/src/renderer/features/settings/components/playback/playback-tab.tsx index e43168e3f..aac18f0c7 100644 --- a/src/renderer/features/settings/components/playback/playback-tab.tsx +++ b/src/renderer/features/settings/components/playback/playback-tab.tsx @@ -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 = () => { }>{hasFancyAudio && } + + ); }; diff --git a/src/renderer/features/settings/components/playback/player-filter-settings.tsx b/src/renderer/features/settings/components/playback/player-filter-settings.tsx new file mode 100644 index 000000000..b643a412e --- /dev/null +++ b/src/renderer/features/settings/components/playback/player-filter-settings.tsx @@ -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 = { + 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 ( + + handleFieldChange(filter.id, e as PlayerFilterField) + } + value={filter.field} + width="25%" + /> +