fix: add tags and show_tags fields to PnC builder (#1626)

This commit is contained in:
Christian Benincasa
2026-01-28 17:38:18 -05:00
committed by GitHub
parent 5a400bf8ed
commit e13ceb514a
12 changed files with 129 additions and 77 deletions

View File

@@ -27,7 +27,7 @@ export const trashApi: RouterPluginAsyncCallback = async (fastify) => {
key: 'state',
name: '',
op: '=',
type: 'facted_string',
type: 'faceted_string',
value: ['missing'],
},
} satisfies SearchFilter;
@@ -46,7 +46,7 @@ export const trashApi: RouterPluginAsyncCallback = async (fastify) => {
name: '',
op: 'in',
value: req.query.itemTypes,
type: 'facted_string',
type: 'faceted_string',
},
},
],

View File

@@ -195,7 +195,7 @@ describe('MeilisearchService.buildFilterExpression', () => {
fieldSpec: {
key: 'genres.name',
name: 'Genre',
type: 'facted_string',
type: 'faceted_string',
op: 'in',
value: ['comedy'],
},
@@ -214,7 +214,7 @@ describe('MeilisearchService.buildFilterExpression', () => {
fieldSpec: {
key: 'genres.name',
name: 'Genre',
type: 'facted_string',
type: 'faceted_string',
op: 'in',
value: ['comedy', 'horror', 'action'],
},
@@ -920,7 +920,7 @@ describe('MeilisearchService.buildFilterExpression', () => {
fieldSpec: {
key: 'genres.name',
name: 'Genre',
type: 'facted_string',
type: 'faceted_string',
op: 'in',
value: ['comedy', 'horror'],
},
@@ -979,7 +979,7 @@ describe('MeilisearchService.buildFilterExpression', () => {
fieldSpec: {
key: 'tags',
name: 'Tags',
type: 'facted_string',
type: 'faceted_string',
op: 'in',
value: ['trending', 'award-winner'],
},
@@ -1016,7 +1016,7 @@ describe('MeilisearchService.buildFilterExpression', () => {
fieldSpec: {
key: 'genres.name',
name: 'Genre',
type: 'facted_string',
type: 'faceted_string',
op: 'not in',
value: ['horror', 'thriller'],
},

View File

@@ -1380,7 +1380,7 @@ export class MeilisearchService implements ISearchService {
case 'value': {
const maybeOpAndValue = match(query.fieldSpec)
.with(
{ type: P.union('facted_string', 'string'), value: P.array() },
{ type: P.union('faceted_string', 'string'), value: P.array() },
({ value, op }) => {
const filteredValue = seq.collect(value, (v) =>
isNonEmptyString(v) ? v : null,

View File

@@ -0,0 +1,10 @@
import { titleToSortTitle } from './programs.ts';
describe('program utils', () => {
describe('titleToSortTitle', () => {
test('numeric titles', () => {
const result = titleToSortTitle('2 Fast 2 Furious');
console.log(result);
});
});
});

View File

@@ -1035,7 +1035,7 @@ export function normalizeSearchFilter(input: SearchFilter): SearchFilter {
.with(
{
type: 'value',
fieldSpec: { type: P.union('facted_string', 'string') },
fieldSpec: { type: P.union('faceted_string', 'string') },
},
({ fieldSpec }) => {
const key: string =

View File

@@ -12,22 +12,26 @@ export function intersperse<T>(arr: T[], v: T, makeLast: boolean = false): T[] {
return flatMap(arr, (x, i) => (i === 0 && !makeLast ? [x] : [x, v]));
}
type MapperFunc<In, Out> = (t: In, index: number, arr: In[]) => Out;
type TypePredicateFunc<In, Out extends In> = (
t: In,
index: number,
arr: In[],
) => t is Out;
type MapperFunc<
In,
Out,
ArrType extends In[] | ReadonlyArray<In> = In[] | ReadonlyArray<In>,
> = (t: In, index: number, arr: ArrType) => Out;
type TypePredicateFunc<
In,
Out extends In,
ArrType extends In[] | ReadonlyArray<In> = In[] | ReadonlyArray<In>,
> = (t: In, index: number, arr: ArrType) => t is Out;
/**
* Equivalent of compact(map()) but in a single pass on the array
*/
export function collect<T, U extends T>(
arr: T[] | null | undefined,
arr: T[] | ReadonlyArray<T> | null | undefined,
f: TypePredicateFunc<T, U>,
): U[];
export function collect<T, U>(
arr: T[] | null | undefined,
arr: T[] | ReadonlyArray<T> | null | undefined,
f: MapperFunc<T, U | null | undefined>,
): U[];
export function collect<
@@ -36,7 +40,7 @@ export function collect<
Func extends
| MapperFunc<T, U | null | undefined>
| (U extends T ? TypePredicateFunc<T, U> : never),
>(arr: T[] | null | undefined, f: Func): U[] {
>(arr: T[] | ReadonlyArray<T> | null | undefined, f: Func): U[] {
if (isNil(arr)) {
return [];
}

View File

@@ -25,7 +25,7 @@ const StringSearchFieldSchema = z.object({
const FactedStringSearchFieldSchema = z.object({
...StringSearchFieldSchema.shape,
type: z.literal('facted_string'),
type: z.literal('faceted_string'),
});
export type FactedStringSearchField = z.infer<
@@ -63,7 +63,7 @@ export const OperatorsByType = {
string: StringOperators,
numeric: NumericOperators,
date: NumericOperators,
facted_string: StringOperators,
faceted_string: StringOperators,
} satisfies Record<SearchField['type'], ReadonlyArray<string>>;
export const SearchFilterValueNodeSchema = z.object({

View File

@@ -3,7 +3,8 @@ import { useQuery } from '@tanstack/react-query';
import type { MediaSourceId } from '@tunarr/shared';
import { search } from '@tunarr/shared/util';
import type { FactedStringSearchField } from '@tunarr/types/schemas';
import { useMemo } from 'react';
import { isArray } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useDebounceValue } from 'usehooks-ts';
import { postApiProgramsFacetsByFacetNameOptions } from '../../generated/@tanstack/react-query.gen.ts';
@@ -22,11 +23,12 @@ export function FacetStringValueSearchNode({
mediaSourceId?: MediaSourceId;
libraryId?: string;
}) {
const { control } = useFormContext<SearchForm>();
const { control, setValue, watch } = useFormContext<SearchForm>();
const [facetSearchInputValue, setFacetSearchInputValue] = useDebounceValue(
'',
500,
);
const [lastValue, op] = watch([`${formKey}.value`, `${formKey}.op`]);
const facetQuery = useQuery({
...postApiProgramsFacetsByFacetNameOptions({
@@ -50,6 +52,24 @@ export function FacetStringValueSearchNode({
: [];
}, [facetQuery.data]);
const handleValueChange = useCallback(
(newValue: string[], originalOnChange: (...args: unknown[]) => void) => {
if (
(!isArray(lastValue) || lastValue.length < 2) &&
newValue.length > 1 &&
op !== 'in' &&
op !== 'not in'
) {
setValue(`${formKey}.op`, 'in', {
shouldDirty: true,
shouldTouch: true,
});
}
originalOnChange([...newValue]);
},
[formKey, lastValue, op, setValue],
);
return (
<Controller
control={control}
@@ -64,7 +84,7 @@ export function FacetStringValueSearchNode({
multiple
filterOptions={(x) => x}
onChange={(_, newValue) => {
field.onChange([...newValue.values()]);
handleValueChange(newValue, field.onChange);
}}
filterSelectedOptions
autoComplete

View File

@@ -11,8 +11,9 @@ import {
import { search, seq } from '@tunarr/shared/util';
import type { SearchField, SearchFilterValueNode } from '@tunarr/types/schemas';
import { OperatorsByType } from '@tunarr/types/schemas';
import { find, flatten, head, isArray, isNumber, map } from 'lodash-es';
import { find, head, isArray, isNumber } from 'lodash-es';
import { useCallback } from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
import type { SearchFieldSpec } from '../../helpers/searchBuilderConstants.ts';
import {
@@ -48,25 +49,6 @@ export function SearchValueNode(props: ValueNodeProps) {
const getFieldName = useGetFieldName(formKey);
const dayjs = useDayjs();
// useEffect(() => {
// const sub = watch((value, { name }) => {
// if (name === getFieldName('fieldSpec.value')) {
// const fieldValue = get(
// value,
// getFieldName('fieldSpec').split('.'),
// ) as SearchField;
// if (
// (fieldValue.type === 'facted_string' ||
// fieldValue.type === 'string') &&
// fieldValue.value.length > 1
// ) {
// setValue(getFieldName('fieldSpec.op'), 'in');
// }
// }
// });
// return () => sub.unsubscribe();
// }, [formKey, getFieldName, setValue, watch]);
const handleFieldChange = useCallback(
(newField: string) => {
const spec = find(
@@ -89,7 +71,7 @@ export function SearchValueNode(props: ValueNodeProps) {
value: [],
};
break;
case 'facted_string':
case 'faceted_string':
fieldSpec = {
key: spec.alias ?? spec.key,
type: spec.type,
@@ -159,7 +141,6 @@ export function SearchValueNode(props: ValueNodeProps) {
value: string,
originalOnChange: (...args: unknown[]) => void,
) => {
console.log(value);
if (selfValue.fieldSpec.type === 'numeric') {
if (spec.normalizer) {
originalOnChange(spec.normalizer(value));
@@ -190,7 +171,7 @@ export function SearchValueNode(props: ValueNodeProps) {
return;
}
if (fieldSpec.type === 'facted_string') {
if (fieldSpec.type === 'faceted_string') {
return (
<FacetStringValueSearchNode
formKey={getFieldName('fieldSpec')}
@@ -226,6 +207,41 @@ export function SearchValueNode(props: ValueNodeProps) {
}
};
const renderOperatorInput = useCallback(
(
field: ControllerRenderProps<SearchForm, `${FieldPrefix}.fieldSpec.op`>,
) => {
const multiple =
isArray(selfValue.fieldSpec.value) &&
selfValue.fieldSpec.value.length > 1;
const operators = seq.collect(
OperatorsByType[selfValue.fieldSpec.type],
(operator) => {
if (multiple && operator !== 'in' && operator !== 'not in') {
return;
}
return (
<MenuItem key={operator} value={operator}>
{getOperatorLabel(selfValue.fieldSpec.type, operator)}
</MenuItem>
);
},
);
return (
<Select
label="Operator"
{...field}
value={field.value}
onChange={(ev) => handleOpChange(ev.target.value, field.onChange)}
>
{operators}
</Select>
);
},
[handleOpChange, selfValue.fieldSpec.type, selfValue.fieldSpec.value],
);
return (
<Stack
gap={1}
@@ -274,22 +290,7 @@ export function SearchValueNode(props: ValueNodeProps) {
<Controller
control={control}
name={getFieldName('fieldSpec.op')}
render={({ field }) => (
<Select
label="Operator"
{...field}
value={field.value}
onChange={(ev) => handleOpChange(ev.target.value, field.onChange)}
>
{flatten(
map(OperatorsByType[selfValue.fieldSpec.type], (op) => (
<MenuItem key={op} value={op}>
{getOperatorLabel(selfValue.fieldSpec.type, op)}
</MenuItem>
)),
)}
</Select>
)}
render={({ field }) => renderOperatorInput(field)}
/>
</FormControl>
{renderValueInput()}

View File

@@ -849,7 +849,7 @@ export type SearchFilterInput = {
} | {
key: string;
name: string;
type: 'facted_string';
type: 'faceted_string';
op: '=' | '!=' | 'contains' | 'starts with' | 'in' | 'not in';
value: Array<string>;
} | {
@@ -1722,7 +1722,7 @@ export type SearchFilter = {
} | {
key: string;
name: string;
type: 'facted_string';
type: 'faceted_string';
op: '=' | '!=' | 'contains' | 'starts with' | 'in' | 'not in';
value: Array<string>;
} | {

View File

@@ -47,13 +47,13 @@ const SecondsField = {
} satisfies SearchFieldSpec<'numeric'>;
export const SearchFieldSpecs: NonEmptyArray<
| SearchFieldSpec<'string' | 'facted_string'>
| SearchFieldSpec<'string' | 'faceted_string'>
| SearchFieldSpec<'numeric' | 'date'>
> = [
TitleSearchFieldSpec,
{
key: 'type',
type: 'facted_string' as const,
type: 'faceted_string' as const,
name: 'Type',
uiVisible: true,
visibleForLibraryTypes: 'all',
@@ -71,14 +71,14 @@ export const SearchFieldSpecs: NonEmptyArray<
{
key: 'genres.name',
alias: 'genre',
type: 'facted_string' as const,
type: 'faceted_string' as const,
name: 'Genre',
uiVisible: true,
visibleForLibraryTypes: 'all',
},
{
key: 'rating',
type: 'facted_string' as const,
type: 'faceted_string' as const,
name: 'Content Rating',
uiVisible: true,
visibleForLibraryTypes: ['movies', 'shows'],
@@ -86,7 +86,7 @@ export const SearchFieldSpecs: NonEmptyArray<
{
key: 'actors.name',
alias: 'actor',
type: 'facted_string' as const,
type: 'faceted_string' as const,
name: 'Actors',
uiVisible: true,
visibleForLibraryTypes: 'all',
@@ -94,7 +94,7 @@ export const SearchFieldSpecs: NonEmptyArray<
{
key: 'writer.name',
alias: 'writer',
type: 'facted_string' as const,
type: 'faceted_string' as const,
name: 'Writers',
uiVisible: true,
visibleForLibraryTypes: 'all',
@@ -102,7 +102,7 @@ export const SearchFieldSpecs: NonEmptyArray<
{
key: 'director.name',
alias: 'director',
type: 'facted_string' as const,
type: 'faceted_string' as const,
name: 'Directors',
uiVisible: true,
visibleForLibraryTypes: 'all',
@@ -110,7 +110,7 @@ export const SearchFieldSpecs: NonEmptyArray<
{
key: 'studio.name',
alias: 'studio',
type: 'facted_string' as const,
type: 'faceted_string' as const,
name: 'Studios',
uiVisible: true,
visibleForLibraryTypes: 'all',
@@ -134,7 +134,7 @@ export const SearchFieldSpecs: NonEmptyArray<
{
key: 'videoCodec',
alias: 'video_codec',
type: 'facted_string' as const,
type: 'faceted_string' as const,
name: 'Video Codec',
uiVisible: true,
visibleForLibraryTypes: 'all',
@@ -166,7 +166,7 @@ export const SearchFieldSpecs: NonEmptyArray<
{
key: 'audioCodec',
alias: 'audio_codec',
type: 'facted_string' as const,
type: 'faceted_string' as const,
name: 'Audio Codec',
uiVisible: true,
visibleForLibraryTypes: 'all',
@@ -181,6 +181,23 @@ export const SearchFieldSpecs: NonEmptyArray<
},
MinutesField,
SecondsField,
{
key: 'tags',
type: 'faceted_string',
name: 'Tags',
uiVisible: true,
visibleForLibraryTypes: 'all',
},
{
key: 'grandparent.tags',
alias: 'show_tags',
type: 'faceted_string' as const,
name: 'Show Tags',
uiVisible: true,
visibleForLibraryTypes: ['shows'] as NoInfer<
ReadonlyArray<MediaSourceLibrary['mediaType']>
>,
} satisfies SearchFieldSpec<'faceted_string'>,
];
interface Bij<In, Out = In> {
@@ -243,7 +260,7 @@ const OperatorLabelByFieldType = {
'>=': 'greater than or equal',
to: 'between',
},
facted_string: {
faceted_string: {
'!=': '!=',
'=': '=',
'starts with': 'starts with',

View File

@@ -58,7 +58,7 @@ export const TrashPage = () => {
key: 'state',
name: '',
op: '=',
type: 'facted_string',
type: 'faceted_string',
value: ['missing'],
},
} satisfies SearchFilter;
@@ -79,7 +79,7 @@ export const TrashPage = () => {
name: '',
op: 'in',
value: itemTypes,
type: 'facted_string',
type: 'faceted_string',
},
},
],