support limitPercent for smart playlists

This commit is contained in:
jeffvli
2026-03-31 21:09:13 -07:00
parent de403ea6ac
commit d3881ee3be
6 changed files with 134 additions and 21 deletions
@@ -72,6 +72,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
? { ? {
...convertQueryGroupToNDQuery(smartPlaylist.filters), ...convertQueryGroupToNDQuery(smartPlaylist.filters),
limit: smartPlaylist.extraFilters.limit, limit: smartPlaylist.extraFilters.limit,
limitPercent: smartPlaylist.extraFilters.limitPercent,
// order field is now optional - sort direction is embedded in sort field // order field is now optional - sort direction is embedded in sort field
sort: sortValue || '+dateAdded', sort: sortValue || '+dateAdded',
} }
@@ -32,6 +32,7 @@ import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select'; import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { useForm } from '/@/shared/hooks/use-form'; import { useForm } from '/@/shared/hooks/use-form';
@@ -51,6 +52,7 @@ type DeleteArgs = {
interface PlaylistQueryBuilderProps { interface PlaylistQueryBuilderProps {
limit?: number; limit?: number;
limitPercent?: number;
playlistId?: string; playlistId?: string;
query: any; query: any;
sortBy: SongListSort | SongListSort[]; sortBy: SongListSort | SongListSort[];
@@ -155,6 +157,7 @@ export type PlaylistQueryBuilderRef = {
getFilters: () => { getFilters: () => {
extraFilters: { extraFilters: {
limit?: number; limit?: number;
limitPercent?: number;
sortBy?: string[]; sortBy?: string[];
sortOrder?: string; sortOrder?: string;
}; };
@@ -164,7 +167,7 @@ export type PlaylistQueryBuilderRef = {
export const PlaylistQueryBuilder = forwardRef( export const PlaylistQueryBuilder = forwardRef(
( (
{ limit, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps, { limit, limitPercent, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,
ref: Ref<PlaylistQueryBuilderRef>, ref: Ref<PlaylistQueryBuilderRef>,
) => { ) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -213,6 +216,8 @@ export const PlaylistQueryBuilder = forwardRef(
const extraFiltersForm = useForm({ const extraFiltersForm = useForm({
initialValues: { initialValues: {
limit, limit,
limitMode: limitPercent != null ? 'limitPercent' : 'limit',
limitPercent,
sortEntries: initialSortEntries, sortEntries: initialSortEntries,
}, },
}); });
@@ -224,16 +229,26 @@ export const PlaylistQueryBuilder = forwardRef(
const sortString = convertSortEntriesToSortString( const sortString = convertSortEntriesToSortString(
extraFiltersForm.values.sortEntries, extraFiltersForm.values.sortEntries,
); );
const isLimitPercent = extraFiltersForm.values.limitMode === 'limitPercent';
return { return {
extraFilters: { extraFilters: {
limit: extraFiltersForm.values.limit, limit: isLimitPercent ? undefined : extraFiltersForm.values.limit,
limitPercent: isLimitPercent
? extraFiltersForm.values.limitPercent
: undefined,
sortBy: sortString ? [sortString] : undefined, sortBy: sortString ? [sortString] : undefined,
}, },
filters, filters,
}; };
}, },
}), }),
[extraFiltersForm.values.sortEntries, extraFiltersForm.values.limit, filters], [
extraFiltersForm.values.sortEntries,
extraFiltersForm.values.limit,
extraFiltersForm.values.limitMode,
extraFiltersForm.values.limitPercent,
filters,
],
); );
const handleResetFilters = useCallback(() => { const handleResetFilters = useCallback(() => {
@@ -608,10 +623,50 @@ export const PlaylistQueryBuilder = forwardRef(
))} ))}
</Stack> </Stack>
<NumberInput <NumberInput
label={t('common.limit', { postProcess: 'titleCase' })} label={
maxWidth="20%" <Group align="center" gap="xs" wrap="nowrap">
{t('common.limit', { postProcess: 'titleCase' })}
<SegmentedControl
data={[
{ label: '#', value: 'limit' },
{ label: '%', value: 'limitPercent' },
]}
onChange={(value) =>
extraFiltersForm.setFieldValue(
'limitMode',
value as 'limit' | 'limitPercent',
)
}
size="xs"
value={extraFiltersForm.values.limitMode}
/>
</Group>
}
max={
extraFiltersForm.values.limitMode === 'limitPercent'
? 100
: undefined
}
min={
extraFiltersForm.values.limitMode === 'limitPercent'
? 0
: undefined
}
onChange={(value) => {
const nextValue =
value === '' || value == null ? undefined : Number(value);
if (extraFiltersForm.values.limitMode === 'limitPercent') {
extraFiltersForm.setFieldValue('limitPercent', nextValue);
} else {
extraFiltersForm.setFieldValue('limit', nextValue);
}
}}
value={
extraFiltersForm.values.limitMode === 'limitPercent'
? extraFiltersForm.values.limitPercent
: extraFiltersForm.values.limit
}
width={75} width={75}
{...extraFiltersForm.getInputProps('limit')}
/> />
</Group> </Group>
</Stack> </Stack>
@@ -28,11 +28,21 @@ export interface PlaylistQueryEditorProps {
detailQuery: ReturnType<typeof useQuery<any>>; detailQuery: ReturnType<typeof useQuery<any>>;
handleSave: ( handleSave: (
filter: Record<string, any>, filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, extraFilters: {
limit?: number;
limitPercent?: number;
sortBy?: string[];
sortOrder?: string;
},
) => void; ) => void;
handleSaveAs: ( handleSaveAs: (
filter: Record<string, any>, filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, extraFilters: {
limit?: number;
limitPercent?: number;
sortBy?: string[];
sortOrder?: string;
},
) => void; ) => void;
isQueryBuilderExpanded: boolean; isQueryBuilderExpanded: boolean;
onToggleExpand: () => void; onToggleExpand: () => void;
@@ -43,6 +53,7 @@ export interface PlaylistQueryEditorProps {
type AppliedJsonState = { type AppliedJsonState = {
limit?: number; limit?: number;
limitPercent?: number;
query: Record<string, any>; query: Record<string, any>;
sort?: string; sort?: string;
}; };
@@ -50,7 +61,7 @@ type AppliedJsonState = {
type EditorMode = 'builder' | 'json'; type EditorMode = 'builder' | 'json';
const serializeFiltersToRulesJson = (filters: { const serializeFiltersToRulesJson = (filters: {
extraFilters: { limit?: number; sortBy?: string[] }; extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] };
filters: any; filters: any;
}): Record<string, any> => { }): Record<string, any> => {
const queryValue = convertQueryGroupToNDQuery(filters.filters); const queryValue = convertQueryGroupToNDQuery(filters.filters);
@@ -58,18 +69,25 @@ const serializeFiltersToRulesJson = (filters: {
return { return {
...queryValue, ...queryValue,
...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }), ...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }),
...(filters.extraFilters.limitPercent != null && {
limitPercent: filters.extraFilters.limitPercent,
}),
...(sortString && { sort: sortString }), ...(sortString && { sort: sortString }),
}; };
}; };
const parseRulesJsonToSaveArgs = ( const parseRulesJsonToSaveArgs = (
parsed: Record<string, any>, parsed: Record<string, any>,
): { extraFilters: { limit?: number; sortBy?: string[] }; filter: Record<string, any> } => { ): {
extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] };
filter: Record<string, any>;
} => {
const rootKey = parsed.all ? 'all' : 'any'; const rootKey = parsed.all ? 'all' : 'any';
const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] }; const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] };
return { return {
extraFilters: { extraFilters: {
...(parsed.limit != null && { limit: parsed.limit }), ...(parsed.limit != null && { limit: parsed.limit }),
...(parsed.limitPercent != null && { limitPercent: parsed.limitPercent }),
...(parsed.sort != null && { sortBy: [parsed.sort] }), ...(parsed.sort != null && { sortBy: [parsed.sort] }),
}, },
filter, filter,
@@ -93,7 +111,12 @@ export const PlaylistQueryEditor = ({
const [appliedJsonState, setAppliedJsonState] = useState<AppliedJsonState | null>(null); const [appliedJsonState, setAppliedJsonState] = useState<AppliedJsonState | null>(null);
const getFiltersForSave = useCallback((): null | { const getFiltersForSave = useCallback((): null | {
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }; extraFilters: {
limit?: number;
limitPercent?: number;
sortBy?: string[];
sortOrder?: string;
};
filter: Record<string, any>; filter: Record<string, any>;
} => { } => {
if (editorMode === 'json') { if (editorMode === 'json') {
@@ -124,6 +147,9 @@ export const PlaylistQueryEditor = ({
const previewValue = { const previewValue = {
...payload.filter, ...payload.filter,
...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }), ...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }),
...(payload.extraFilters.limitPercent != null && {
limitPercent: payload.extraFilters.limitPercent,
}),
...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }), ...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }),
}; };
openModal({ openModal({
@@ -208,6 +234,8 @@ export const PlaylistQueryEditor = ({
[appliedJsonState?.query, detailQuery?.data?.rules], [appliedJsonState?.query, detailQuery?.data?.rules],
); );
const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit; const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit;
const effectiveLimitPercent =
appliedJsonState?.limitPercent ?? detailQuery?.data?.rules?.limitPercent;
const effectiveSortBy = useMemo( const effectiveSortBy = useMemo(
() => () =>
(appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as (appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as
@@ -233,6 +261,8 @@ export const PlaylistQueryEditor = ({
? { ...effectiveQuery } ? { ...effectiveQuery }
: { all: [] }; : { all: [] };
if (effectiveLimit != null) fallback.limit = effectiveLimit; if (effectiveLimit != null) fallback.limit = effectiveLimit;
if (effectiveLimitPercent != null)
fallback.limitPercent = effectiveLimitPercent;
if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0]; if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0];
if (!fallback.sort) fallback.sort = '+dateAdded'; if (!fallback.sort) fallback.sort = '+dateAdded';
setJsonText(JSON.stringify(fallback, null, 2)); setJsonText(JSON.stringify(fallback, null, 2));
@@ -248,6 +278,7 @@ export const PlaylistQueryEditor = ({
} }
setAppliedJsonState({ setAppliedJsonState({
limit: parsed.limit, limit: parsed.limit,
limitPercent: parsed.limitPercent,
query: { [rootKey]: parsed[rootKey] }, query: { [rootKey]: parsed[rootKey] },
sort: parsed.sort, sort: parsed.sort,
}); });
@@ -263,7 +294,16 @@ export const PlaylistQueryEditor = ({
setEditorMode('builder'); setEditorMode('builder');
} }
}, },
[editorMode, effectiveLimit, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t], [
editorMode,
effectiveLimit,
effectiveLimitPercent,
effectiveQuery,
effectiveSortBy,
jsonText,
queryBuilderRef,
t,
],
); );
return ( return (
@@ -367,6 +407,7 @@ export const PlaylistQueryEditor = ({
<PlaylistQueryBuilder <PlaylistQueryBuilder
key={JSON.stringify(appliedJsonState ?? detailQuery?.data?.rules)} key={JSON.stringify(appliedJsonState ?? detailQuery?.data?.rules)}
limit={effectiveLimit} limit={effectiveLimit}
limitPercent={effectiveLimitPercent}
playlistId={playlistId} playlistId={playlistId}
query={effectiveQuery} query={effectiveQuery}
ref={queryBuilderRef} ref={queryBuilderRef}
@@ -85,7 +85,7 @@ const PlaylistDetailSongListRoute = () => {
const handleSave = ( const handleSave = (
filter: Record<string, any>, filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[]; sortOrder?: string },
) => { ) => {
if (!detailQuery?.data) return; if (!detailQuery?.data) return;
@@ -96,7 +96,8 @@ const PlaylistDetailSongListRoute = () => {
const rules = { const rules = {
...filter, ...filter,
limit: extraFilters.limit || undefined, limit: extraFilters.limit ?? undefined,
limitPercent: extraFilters.limitPercent ?? undefined,
sort: sortValue, sort: sortValue,
}; };
@@ -123,7 +124,7 @@ const PlaylistDetailSongListRoute = () => {
const handleSaveAs = ( const handleSaveAs = (
filter: Record<string, any>, filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[]; sortOrder?: string },
) => { ) => {
if (!detailQuery?.data) return; if (!detailQuery?.data) return;
@@ -134,7 +135,8 @@ const PlaylistDetailSongListRoute = () => {
const rules = { const rules = {
...filter, ...filter,
limit: extraFilters.limit || undefined, limit: extraFilters.limit ?? undefined,
limitPercent: extraFilters.limitPercent ?? undefined,
sort: sortValue, sort: sortValue,
}; };
+10 -2
View File
@@ -600,6 +600,14 @@ const songListParameters = paginationParameters.extend({
year: z.number().optional(), year: z.number().optional(),
}); });
const playlistRules = z
.object({
limit: z.number().optional(),
limitPercent: z.number().optional(),
sort: z.string().optional(),
})
.catchall(z.any());
const playlist = z.object({ const playlist = z.object({
comment: z.string(), comment: z.string(),
createdAt: z.string(), createdAt: z.string(),
@@ -611,7 +619,7 @@ const playlist = z.object({
ownerName: z.string(), ownerName: z.string(),
path: z.string(), path: z.string(),
public: z.boolean(), public: z.boolean(),
rules: z.record(z.string(), z.any()), rules: playlistRules,
size: z.number(), size: z.number(),
songCount: z.number(), songCount: z.number(),
sync: z.boolean(), sync: z.boolean(),
@@ -643,7 +651,7 @@ const createPlaylistParameters = z.object({
name: z.string(), name: z.string(),
ownerId: z.string().optional(), ownerId: z.string().optional(),
public: z.boolean().optional(), public: z.boolean().optional(),
rules: z.record(z.any()).optional(), rules: playlistRules.optional(),
sync: z.boolean().optional(), sync: z.boolean().optional(),
}); });
+9 -3
View File
@@ -340,7 +340,7 @@ export type Playlist = {
owner: null | string; owner: null | string;
ownerId: null | string; ownerId: null | string;
public: boolean | null; public: boolean | null;
rules?: null | Record<string, any>; rules?: null | PlaylistRules;
size: null | number; size: null | number;
songCount: null | number; songCount: null | number;
sync?: boolean | null; sync?: boolean | null;
@@ -947,7 +947,7 @@ export type CreatePlaylistBody = {
name: string; name: string;
ownerId?: string; ownerId?: string;
public?: boolean; public?: boolean;
queryBuilderRules?: Record<string, any>; queryBuilderRules?: PlaylistRules;
sync?: boolean; sync?: boolean;
}; };
@@ -1009,6 +1009,12 @@ export interface PlaylistListQuery extends BaseQuery<PlaylistListSort> {
// Playlist List // Playlist List
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>; export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
export type PlaylistRules = Record<string, any> & {
limit?: number;
limitPercent?: number;
sort?: string;
};
export type RatingQuery = { export type RatingQuery = {
id: string[]; id: string[];
rating: number; rating: number;
@@ -1089,7 +1095,7 @@ export type UpdatePlaylistBody = {
name: string; name: string;
ownerId?: string; ownerId?: string;
public?: boolean; public?: boolean;
queryBuilderRules?: Record<string, any>; queryBuilderRules?: PlaylistRules;
sync?: boolean; sync?: boolean;
}; };