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 (
+