support custom smart playlist tags

This commit is contained in:
jeffvli
2025-11-29 15:56:18 -08:00
parent d22fee887c
commit 6094a520e2
7 changed files with 278 additions and 15 deletions
+11 -1
View File
@@ -481,6 +481,7 @@
"updates": "update",
"cache": "cache",
"application": "application",
"queryBuilder": "query builder",
"theme": "theme",
"controls": "controls",
"sidebar": "sidebar",
@@ -553,6 +554,10 @@
"pause": "pause",
"viewQueue": "view queue"
},
"queryBuilder": {
"standardTags": "standard tags",
"customTags": "custom tags"
},
"releaseType": {
"primary": {
"album": "$t(entity.album_one)",
@@ -856,7 +861,12 @@
"windowBarStyle_description": "select the style of the window bar",
"windowBarStyle": "window bar style",
"zoom_description": "sets the zoom percentage for the application",
"zoom": "zoom percentage"
"zoom": "zoom percentage",
"queryBuilder": "query builder",
"queryBuilderCustomFields_inputLabel": "label",
"queryBuilderCustomFields_inputTag": "tag",
"queryBuilderCustomFields": "custom fields",
"queryBuilderCustomFields_description": "add custom fields to use in query builders"
},
"table": {
"column": {
@@ -11,19 +11,24 @@ import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types';
export type FilterGroup = { group: string; items: FilterItem[] };
export type FilterItem = { label: string; type: string; value: string };
export type Filters = FilterGroup[] | FilterItem[];
type AddArgs = {
groupIndex: number[];
level: number;
};
type DeleteArgs = {
groupIndex: number[];
level: number;
uniqueId: string;
};
interface QueryBuilderProps {
data: Record<string, any>;
filters: { label: string; type: string; value: string }[];
filters: Filters;
groupIndex: number[];
level: number;
onAddRule: (args: AddArgs) => void;
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Filters } from '/@/renderer/components/query-builder';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
@@ -15,7 +16,7 @@ type DeleteArgs = {
interface QueryOptionProps {
data: QueryBuilderRule;
filters: { label: string; type: string; value: string }[];
filters: Filters;
groupIndex: number[];
level: number;
noRemove: boolean;
@@ -165,7 +166,11 @@ export const QueryBuilderOption = ({
});
};
const fieldType = filters.find((f) => f.value === field)?.type;
// Handle both grouped and flat filter data
const flatFilters = filters.some((f: any) => f.group && f.items)
? filters.flatMap((group: any) => group.items || [])
: filters;
const fieldType = flatFilters.find((f: any) => f.value === field)?.type;
const operatorsByFieldType = operators[fieldType as keyof typeof operators];
const ml = 20;
@@ -18,6 +18,7 @@ import { QueryBuilder } from '/@/renderer/components/query-builder';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { convertNDQueryToQueryGroup } from '/@/renderer/features/playlists/utils';
import { useCurrentServer } from '/@/renderer/store';
import { useQueryBuilderSettings } from '/@/renderer/store/settings.store';
import {
NDSongQueryBooleanOperators,
NDSongQueryDateOperators,
@@ -168,6 +169,7 @@ export const PlaylistQueryBuilder = forwardRef(
) => {
const { t } = useTranslation();
const server = useCurrentServer();
const queryBuilderSettings = useQueryBuilderSettings();
// Memoize initial filters to avoid recalculation
const initialFilters = useMemo(
@@ -412,6 +414,54 @@ export const PlaylistQueryBuilder = forwardRef(
});
}, []);
const customFields = useMemo(() => {
return queryBuilderSettings.tag
.filter((field) => field.value && field.value.trim() !== '')
.map((field) => ({
label: field.label,
type: field.type,
value: field.value,
}));
}, [queryBuilderSettings.tag]);
const groupedFilters = useMemo(() => {
type FilterGroup = {
group: string;
items: Array<{ label: string; type: string; value: string }>;
};
const groups: FilterGroup[] = [];
// Custom Fields group
if (customFields.length > 0) {
groups.push({
group: t('queryBuilder.customTags', {
postProcess: 'titleCase',
}),
items: customFields,
});
}
// Standard Fields group
if (NDSongQueryFields.length > 0) {
groups.push({
group: t('queryBuilder.standardTags', {
postProcess: 'titleCase',
}),
items: NDSongQueryFields,
});
}
if (groups.length === 0) {
return NDSongQueryFields;
}
if (groups.length === 1) {
return groups[0].items;
}
return groups;
}, [customFields, t]);
// Memoize sort options
const sortOptions = useMemo(
() => [
@@ -483,7 +533,7 @@ export const PlaylistQueryBuilder = forwardRef(
<Stack gap="md" h="100%" p="1rem">
<QueryBuilder
data={filters}
filters={NDSongQueryFields}
filters={groupedFilters}
groupIndex={[]}
level={0}
onAddRule={handleAddRule}
@@ -1,24 +1,40 @@
import { useMemo } from 'react';
import { Fragment } from 'react/jsx-runtime';
import { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings';
import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings';
import { LyricSettings } from '/@/renderer/features/settings/components/general/lyric-settings';
import { QueryBuilderSettings } from '/@/renderer/features/settings/components/general/query-builder-settings';
import { ScrobbleSettings } from '/@/renderer/features/settings/components/general/scrobble-settings';
import { SidebarSettings } from '/@/renderer/features/settings/components/general/sidebar-settings';
import { ThemeSettings } from '/@/renderer/features/settings/components/general/theme-settings';
import { useCurrentServer } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { Divider } from '/@/shared/components/divider/divider';
import { Stack } from '/@/shared/components/stack/stack';
const sections = [
{ component: ApplicationSettings, key: 'application' },
{ component: ThemeSettings, key: 'theme' },
{ component: ControlSettings, key: 'control' },
{ component: SidebarSettings, key: 'sidebar' },
{ component: ScrobbleSettings, key: 'scrobble' },
{ component: LyricSettings, key: 'lyrics' },
];
import { ServerFeature } from '/@/shared/types/features-types';
export const GeneralTab = () => {
const server = useCurrentServer();
const supportsSmartPlaylists = hasFeature(server, ServerFeature.PLAYLISTS_SMART);
const sections = useMemo(() => {
const baseSections = [
{ component: ApplicationSettings, key: 'application' },
{ component: ThemeSettings, key: 'theme' },
{ component: ControlSettings, key: 'control' },
{ component: SidebarSettings, key: 'sidebar' },
{ component: ScrobbleSettings, key: 'scrobble' },
{ component: LyricSettings, key: 'lyrics' },
];
if (supportsSmartPlaylists) {
baseSections.push({ component: QueryBuilderSettings, key: 'queryBuilder' });
}
return baseSections;
}, [supportsSmartPlaylists]);
return (
<Stack gap="md">
{sections.map(({ component: Section, key }, index) => (
@@ -0,0 +1,151 @@
import { useTranslation } from 'react-i18next';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { useQueryBuilderSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
const QUERY_VALUE_INPUT_TYPES = [
{ label: 'Boolean', value: 'boolean' },
{ label: 'Date', value: 'date' },
{ label: 'Date Range', value: 'dateRange' },
{ label: 'Number', value: 'number' },
{ label: 'Playlist', value: 'playlist' },
{ label: 'String', value: 'string' },
] as const;
export const QueryBuilderSettings = () => {
const { t } = useTranslation();
const queryBuilder = useQueryBuilderSettings();
const { setSettings } = useSettingsStoreActions();
const handleAddCustomField = () => {
setSettings({
queryBuilder: {
tag: [
...queryBuilder.tag,
{
label: '',
type: 'string',
value: '',
},
],
},
});
};
const handleRemoveCustomField = (index: number) => {
setSettings({
queryBuilder: {
tag: queryBuilder.tag.filter((_, i) => i !== index),
},
});
};
const handleUpdateCustomField = (
index: number,
field: 'label' | 'type' | 'value',
newValue: string,
) => {
setSettings({
queryBuilder: {
tag: queryBuilder.tag.map((item, i) =>
i === index ? { ...item, [field]: newValue } : item,
),
},
});
};
const customFieldsOptions: SettingOption[] = [
{
control: (
<Stack gap="md">
{queryBuilder.tag.length > 0 && (
<Stack gap="sm">
{queryBuilder.tag.map((field, index) => (
<Group gap="sm" key={index}>
<TextInput
onChange={(e) =>
handleUpdateCustomField(
index,
'label',
e.currentTarget.value,
)
}
placeholder={t(
'setting.queryBuilderCustomFields_inputLabel',
{
postProcess: 'sentenceCase',
},
)}
value={field.label}
width="30%"
/>
<Select
data={QUERY_VALUE_INPUT_TYPES}
onChange={(e) =>
handleUpdateCustomField(index, 'type', e || 'string')
}
value={field.type}
width="25%"
/>
<TextInput
onChange={(e) =>
handleUpdateCustomField(
index,
'value',
e.currentTarget.value,
)
}
placeholder={t(
'setting.queryBuilderCustomFields_inputTag',
{
postProcess: 'sentenceCase',
},
)}
value={field.value}
width="30%"
/>
<ActionIcon
icon="remove"
onClick={() => handleRemoveCustomField(index)}
size="sm"
variant="subtle"
/>
</Group>
))}
</Stack>
)}
<Group grow>
<Button onClick={handleAddCustomField} variant="filled">
{t('common.add', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>
),
description: t('setting.queryBuilderCustomFields', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.queryBuilderCustomFields', {
postProcess: 'sentenceCase',
}),
},
];
return (
<SettingsSection
options={customFieldsOptions}
title={t('page.setting.queryBuilder', {
postProcess: 'sentenceCase',
})}
/>
);
};
+26
View File
@@ -330,6 +330,25 @@ const WindowSettingsSchema = z.object({
windowBarStyle: z.nativeEnum(Platform),
});
const QueryValueInputTypeSchema = z.enum([
'boolean',
'date',
'dateRange',
'number',
'playlist',
'string',
]);
const QueryBuilderCustomFieldSchema = z.object({
label: z.string(),
type: QueryValueInputTypeSchema,
value: z.string(),
});
const QueryBuilderSettingsSchema = z.object({
tag: z.array(QueryBuilderCustomFieldSchema),
});
/**
* This schema is used for validation of the imported settings json
*/
@@ -342,6 +361,7 @@ export const ValidationSettingsStateSchema = z.object({
lists: z.record(z.nativeEnum(ItemListKey), ItemListConfigSchema),
lyrics: LyricsSettingsSchema,
playback: PlaybackSettingsSchema,
queryBuilder: QueryBuilderSettingsSchema,
remote: RemoteSettingsSchema,
tab: z.union([
z.literal('general'),
@@ -1105,6 +1125,9 @@ const initialState: SettingsState = {
type: PlayerType.WEB,
webAudio: true,
},
queryBuilder: {
tag: [],
},
remote: {
enabled: false,
password: randomString(8),
@@ -1297,6 +1320,9 @@ export const useDiscordSettings = () => useSettingsStore((state) => state.discor
export const useCssSettings = () => useSettingsStore((state) => state.css, shallow);
export const useQueryBuilderSettings = () =>
useSettingsStore((state) => state.queryBuilder, shallow);
const getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;
export const useSettingsForExport = (): SettingsState & { version: number } =>