mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
fix: add tags and show_tags fields to PnC builder (#1626)
This commit is contained in:
committed by
GitHub
parent
5a400bf8ed
commit
e13ceb514a
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
server/src/util/programs.test.ts
Normal file
10
server/src/util/programs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>;
|
||||
} | {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user