From d3881ee3befd55bb6cb53156f4811f0a4fec5d72 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 31 Mar 2026 21:09:13 -0700 Subject: [PATCH] support limitPercent for smart playlists --- .../components/create-playlist-form.tsx | 1 + .../components/playlist-query-builder.tsx | 67 +++++++++++++++++-- .../components/playlist-query-editor.tsx | 53 +++++++++++++-- .../playlist-detail-song-list-route.tsx | 10 +-- src/shared/api/navidrome/navidrome-types.ts | 12 +++- src/shared/types/domain-types.ts | 12 +++- 6 files changed, 134 insertions(+), 21 deletions(-) diff --git a/src/renderer/features/playlists/components/create-playlist-form.tsx b/src/renderer/features/playlists/components/create-playlist-form.tsx index 0c7018e6a..b2e4e1742 100644 --- a/src/renderer/features/playlists/components/create-playlist-form.tsx +++ b/src/renderer/features/playlists/components/create-playlist-form.tsx @@ -72,6 +72,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => { ? { ...convertQueryGroupToNDQuery(smartPlaylist.filters), limit: smartPlaylist.extraFilters.limit, + limitPercent: smartPlaylist.extraFilters.limitPercent, // order field is now optional - sort direction is embedded in sort field sort: sortValue || '+dateAdded', } diff --git a/src/renderer/features/playlists/components/playlist-query-builder.tsx b/src/renderer/features/playlists/components/playlist-query-builder.tsx index bb577865e..f8bf123c9 100644 --- a/src/renderer/features/playlists/components/playlist-query-builder.tsx +++ b/src/renderer/features/playlists/components/playlist-query-builder.tsx @@ -32,6 +32,7 @@ import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; import { NumberInput } from '/@/shared/components/number-input/number-input'; 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 { Stack } from '/@/shared/components/stack/stack'; import { useForm } from '/@/shared/hooks/use-form'; @@ -51,6 +52,7 @@ type DeleteArgs = { interface PlaylistQueryBuilderProps { limit?: number; + limitPercent?: number; playlistId?: string; query: any; sortBy: SongListSort | SongListSort[]; @@ -155,6 +157,7 @@ export type PlaylistQueryBuilderRef = { getFilters: () => { extraFilters: { limit?: number; + limitPercent?: number; sortBy?: string[]; sortOrder?: string; }; @@ -164,7 +167,7 @@ export type PlaylistQueryBuilderRef = { export const PlaylistQueryBuilder = forwardRef( ( - { limit, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps, + { limit, limitPercent, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps, ref: Ref, ) => { const { t } = useTranslation(); @@ -213,6 +216,8 @@ export const PlaylistQueryBuilder = forwardRef( const extraFiltersForm = useForm({ initialValues: { limit, + limitMode: limitPercent != null ? 'limitPercent' : 'limit', + limitPercent, sortEntries: initialSortEntries, }, }); @@ -224,16 +229,26 @@ export const PlaylistQueryBuilder = forwardRef( const sortString = convertSortEntriesToSortString( extraFiltersForm.values.sortEntries, ); + const isLimitPercent = extraFiltersForm.values.limitMode === 'limitPercent'; return { extraFilters: { - limit: extraFiltersForm.values.limit, + limit: isLimitPercent ? undefined : extraFiltersForm.values.limit, + limitPercent: isLimitPercent + ? extraFiltersForm.values.limitPercent + : undefined, sortBy: sortString ? [sortString] : undefined, }, filters, }; }, }), - [extraFiltersForm.values.sortEntries, extraFiltersForm.values.limit, filters], + [ + extraFiltersForm.values.sortEntries, + extraFiltersForm.values.limit, + extraFiltersForm.values.limitMode, + extraFiltersForm.values.limitPercent, + filters, + ], ); const handleResetFilters = useCallback(() => { @@ -608,10 +623,50 @@ export const PlaylistQueryBuilder = forwardRef( ))} + {t('common.limit', { postProcess: 'titleCase' })} + + extraFiltersForm.setFieldValue( + 'limitMode', + value as 'limit' | 'limitPercent', + ) + } + size="xs" + value={extraFiltersForm.values.limitMode} + /> + + } + 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} - {...extraFiltersForm.getInputProps('limit')} /> diff --git a/src/renderer/features/playlists/components/playlist-query-editor.tsx b/src/renderer/features/playlists/components/playlist-query-editor.tsx index 42d2a836c..bd1ff0560 100644 --- a/src/renderer/features/playlists/components/playlist-query-editor.tsx +++ b/src/renderer/features/playlists/components/playlist-query-editor.tsx @@ -28,11 +28,21 @@ export interface PlaylistQueryEditorProps { detailQuery: ReturnType>; handleSave: ( filter: Record, - extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, + extraFilters: { + limit?: number; + limitPercent?: number; + sortBy?: string[]; + sortOrder?: string; + }, ) => void; handleSaveAs: ( filter: Record, - extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, + extraFilters: { + limit?: number; + limitPercent?: number; + sortBy?: string[]; + sortOrder?: string; + }, ) => void; isQueryBuilderExpanded: boolean; onToggleExpand: () => void; @@ -43,6 +53,7 @@ export interface PlaylistQueryEditorProps { type AppliedJsonState = { limit?: number; + limitPercent?: number; query: Record; sort?: string; }; @@ -50,7 +61,7 @@ type AppliedJsonState = { type EditorMode = 'builder' | 'json'; const serializeFiltersToRulesJson = (filters: { - extraFilters: { limit?: number; sortBy?: string[] }; + extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] }; filters: any; }): Record => { const queryValue = convertQueryGroupToNDQuery(filters.filters); @@ -58,18 +69,25 @@ const serializeFiltersToRulesJson = (filters: { return { ...queryValue, ...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }), + ...(filters.extraFilters.limitPercent != null && { + limitPercent: filters.extraFilters.limitPercent, + }), ...(sortString && { sort: sortString }), }; }; const parseRulesJsonToSaveArgs = ( parsed: Record, -): { extraFilters: { limit?: number; sortBy?: string[] }; filter: Record } => { +): { + extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] }; + filter: Record; +} => { const rootKey = parsed.all ? 'all' : 'any'; const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] }; return { extraFilters: { ...(parsed.limit != null && { limit: parsed.limit }), + ...(parsed.limitPercent != null && { limitPercent: parsed.limitPercent }), ...(parsed.sort != null && { sortBy: [parsed.sort] }), }, filter, @@ -93,7 +111,12 @@ export const PlaylistQueryEditor = ({ const [appliedJsonState, setAppliedJsonState] = useState(null); const getFiltersForSave = useCallback((): null | { - extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }; + extraFilters: { + limit?: number; + limitPercent?: number; + sortBy?: string[]; + sortOrder?: string; + }; filter: Record; } => { if (editorMode === 'json') { @@ -124,6 +147,9 @@ export const PlaylistQueryEditor = ({ const previewValue = { ...payload.filter, ...(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] }), }; openModal({ @@ -208,6 +234,8 @@ export const PlaylistQueryEditor = ({ [appliedJsonState?.query, detailQuery?.data?.rules], ); const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit; + const effectiveLimitPercent = + appliedJsonState?.limitPercent ?? detailQuery?.data?.rules?.limitPercent; const effectiveSortBy = useMemo( () => (appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as @@ -233,6 +261,8 @@ export const PlaylistQueryEditor = ({ ? { ...effectiveQuery } : { all: [] }; if (effectiveLimit != null) fallback.limit = effectiveLimit; + if (effectiveLimitPercent != null) + fallback.limitPercent = effectiveLimitPercent; if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0]; if (!fallback.sort) fallback.sort = '+dateAdded'; setJsonText(JSON.stringify(fallback, null, 2)); @@ -248,6 +278,7 @@ export const PlaylistQueryEditor = ({ } setAppliedJsonState({ limit: parsed.limit, + limitPercent: parsed.limitPercent, query: { [rootKey]: parsed[rootKey] }, sort: parsed.sort, }); @@ -263,7 +294,16 @@ export const PlaylistQueryEditor = ({ setEditorMode('builder'); } }, - [editorMode, effectiveLimit, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t], + [ + editorMode, + effectiveLimit, + effectiveLimitPercent, + effectiveQuery, + effectiveSortBy, + jsonText, + queryBuilderRef, + t, + ], ); return ( @@ -367,6 +407,7 @@ export const PlaylistQueryEditor = ({ { const handleSave = ( filter: Record, - extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, + extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[]; sortOrder?: string }, ) => { if (!detailQuery?.data) return; @@ -96,7 +96,8 @@ const PlaylistDetailSongListRoute = () => { const rules = { ...filter, - limit: extraFilters.limit || undefined, + limit: extraFilters.limit ?? undefined, + limitPercent: extraFilters.limitPercent ?? undefined, sort: sortValue, }; @@ -123,7 +124,7 @@ const PlaylistDetailSongListRoute = () => { const handleSaveAs = ( filter: Record, - extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string }, + extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[]; sortOrder?: string }, ) => { if (!detailQuery?.data) return; @@ -134,7 +135,8 @@ const PlaylistDetailSongListRoute = () => { const rules = { ...filter, - limit: extraFilters.limit || undefined, + limit: extraFilters.limit ?? undefined, + limitPercent: extraFilters.limitPercent ?? undefined, sort: sortValue, }; diff --git a/src/shared/api/navidrome/navidrome-types.ts b/src/shared/api/navidrome/navidrome-types.ts index f126685b2..69533b4c6 100644 --- a/src/shared/api/navidrome/navidrome-types.ts +++ b/src/shared/api/navidrome/navidrome-types.ts @@ -600,6 +600,14 @@ const songListParameters = paginationParameters.extend({ 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({ comment: z.string(), createdAt: z.string(), @@ -611,7 +619,7 @@ const playlist = z.object({ ownerName: z.string(), path: z.string(), public: z.boolean(), - rules: z.record(z.string(), z.any()), + rules: playlistRules, size: z.number(), songCount: z.number(), sync: z.boolean(), @@ -643,7 +651,7 @@ const createPlaylistParameters = z.object({ name: z.string(), ownerId: z.string().optional(), public: z.boolean().optional(), - rules: z.record(z.any()).optional(), + rules: playlistRules.optional(), sync: z.boolean().optional(), }); diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 77459a7fc..4fe507d0f 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -340,7 +340,7 @@ export type Playlist = { owner: null | string; ownerId: null | string; public: boolean | null; - rules?: null | Record; + rules?: null | PlaylistRules; size: null | number; songCount: null | number; sync?: boolean | null; @@ -947,7 +947,7 @@ export type CreatePlaylistBody = { name: string; ownerId?: string; public?: boolean; - queryBuilderRules?: Record; + queryBuilderRules?: PlaylistRules; sync?: boolean; }; @@ -1009,6 +1009,12 @@ export interface PlaylistListQuery extends BaseQuery { // Playlist List export type PlaylistListResponse = BasePaginatedResponse; +export type PlaylistRules = Record & { + limit?: number; + limitPercent?: number; + sort?: string; +}; + export type RatingQuery = { id: string[]; rating: number; @@ -1089,7 +1095,7 @@ export type UpdatePlaylistBody = { name: string; ownerId?: string; public?: boolean; - queryBuilderRules?: Record; + queryBuilderRules?: PlaylistRules; sync?: boolean; };