feat: add more searchable fields

This commit is contained in:
Christian Benincasa
2026-04-12 19:57:41 -04:00
parent fcb3ce8b09
commit 9b4fae379f
14 changed files with 4251 additions and 4 deletions

View File

@@ -268,6 +268,8 @@ export const AllProgramFields = [
'program.plexFilePath',
'program.plexRatingKey',
'program.rating',
'program.audienceRating',
'program.criticRating',
'program.seasonIcon',
'program.seasonNumber',
'program.seasonUuid',

View File

@@ -5,6 +5,7 @@ import {
check,
index,
integer,
real,
sqliteTable,
text,
uniqueIndex,
@@ -79,6 +80,8 @@ export const Program = sqliteTable(
plexFilePath: text(),
plexRatingKey: text(),
rating: text(),
audienceRating: real(),
criticRating: real(),
seasonIcon: text(),
seasonNumber: integer(),
seasonUuid: text().references(() => ProgramGrouping.uuid),

View File

@@ -116,6 +116,9 @@ const RequiredLibraryFields = [
'OfficialRating',
'MediaStreams',
'MediaSources',
'ProductionLocations',
'CommunityRating',
'CriticRating',
];
function getJellyfinAuthorization(
@@ -1026,6 +1029,10 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
}),
plot: movie.Overview ?? null,
rating: movie.OfficialRating ?? null,
countries: movie.ProductionLocations?.map((loc) => ({ name: loc })) ?? [],
collections: [],
audienceRating: movie.CommunityRating ?? null,
criticRating: movie.CriticRating ?? null,
sourceType: 'jellyfin',
tagline: find(movie.Taglines, isNonEmptyString) ?? null,
tags: movie.Tags?.filter(isNonEmptyString) ?? [],
@@ -1242,6 +1249,11 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
}),
plot: series.Overview ?? null,
rating: series.OfficialRating ?? null,
countries:
series.ProductionLocations?.map((loc) => ({ name: loc })) ?? [],
collections: [],
audienceRating: series.CommunityRating ?? null,
criticRating: series.CriticRating ?? null,
sourceType: 'jellyfin',
tagline: find(series.Taglines, isNonEmptyString) ?? null,
tags: series.Tags?.filter(isNonEmptyString) ?? [],

View File

@@ -1186,11 +1186,15 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
releaseDateString: releaseDate?.format() ?? null,
actors: plexActorInject(plexShow.Role),
genres: plexJoinItemInject(plexShow.Genre),
countries: plexJoinItemInject(plexShow.Country),
collections: plexJoinItemInject(plexShow.Collection),
plot: plexShow.summary ?? null,
studios: isNonEmptyString(plexShow.studio)
? [{ name: plexShow.studio }]
: [],
rating: plexShow.contentRating ?? null,
audienceRating: plexShow.audienceRating ?? null,
criticRating: null,
summary: null,
tagline: plexShow.tagline ?? null,
identifiers: [
@@ -1538,10 +1542,14 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
writers: plexWriterInject(plexMovie.Writer),
studios,
genres: plexMovie.Genre?.map(({ tag }) => ({ name: tag })) ?? [],
countries: plexJoinItemInject(plexMovie.Country),
collections: plexJoinItemInject(plexMovie.Collection),
summary: plexMovie.summary ?? null,
plot: null,
tagline: plexMovie.tagline ?? null,
rating: plexMovie.contentRating ?? null,
audienceRating: plexMovie.audienceRating ?? null,
criticRating: plexMovie.rating ?? null,
tags: plexMovie.Label?.map((label) => label.tag) ?? [],
externalId: plexMovie.ratingKey,
identifiers: [

View File

@@ -207,6 +207,9 @@ export class DirectMigrationProvider implements MigrationProvider {
migration1775060606: makeKyselyMigrationFromSqlFile(
'./sql/0043_common_zzzax.sql',
),
migration1776016472: makeKyselyMigrationFromSqlFile(
'./sql/0044_add_audience_critic_rating.sql',
),
},
wrapWithTransaction,
),

View File

@@ -0,0 +1,2 @@
ALTER TABLE `program` ADD `audience_rating` real;--> statement-breakpoint
ALTER TABLE `program` ADD `critic_rating` real;

View File

@@ -2,7 +2,7 @@
"version": "6",
"dialect": "sqlite",
"id": "11c0c7ae-4449-4d42-8243-1d68565688fc",
"prevId": "1231ad02-0d3a-4c84-be1e-64b9067f084b",
"prevId": "63234c90-1be6-4af9-985c-db1eb9bb694c",
"tables": {
"artwork": {
"name": "artwork",
@@ -3707,6 +3707,14 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'media'"
}
},
"indexes": {
@@ -3728,7 +3736,8 @@
"name": "tag_program_id_unique_idx",
"columns": [
"tag_id",
"program_id"
"program_id",
"source"
],
"isUnique": true
},
@@ -3736,7 +3745,8 @@
"name": "tag_grouping_id_unique_idx",
"columns": [
"tag_id",
"grouping_id"
"grouping_id",
"source"
],
"isUnique": true
}

File diff suppressed because it is too large Load Diff

View File

@@ -309,6 +309,13 @@
"when": 1775060587475,
"tag": "0043_common_zzzax",
"breakpoints": true
},
{
"idx": 44,
"version": "6",
"when": 1776016472604,
"tag": "0044_add_audience_critic_rating",
"breakpoints": true
}
]
}
}

View File

@@ -165,6 +165,11 @@ const ProgramsIndex: TunarrSearchIndex<ProgramSearchDocument> = {
'grandparent.studio',
'audioLanguages',
'subtitleLanguages',
'summary',
'countries.name',
'collections.name',
'audienceRating',
'criticRating',
],
sortable: [
'title',
@@ -173,6 +178,8 @@ const ProgramsIndex: TunarrSearchIndex<ProgramSearchDocument> = {
'originalReleaseDate',
'originalReleaseYear',
'index',
'audienceRating',
'criticRating',
],
caseSensitiveFilters: [
'grandparent.id',
@@ -277,6 +284,10 @@ type BaseProgramSearchDocument = {
studio?: Studio[];
tags: string[];
state: ProgramState;
countries: StringName[];
collections: StringName[];
audienceRating: Nullable<number>;
criticRating: Nullable<number>;
};
export type TerminalProgramSearchDocument<
@@ -829,6 +840,10 @@ export class MeilisearchService implements ISearchService {
),
tags: show.tags,
studio: show.studios,
countries: show.countries ?? [],
collections: show.collections ?? [],
audienceRating: show.audienceRating ?? null,
criticRating: show.criticRating ?? null,
state: 'ok',
};
@@ -880,6 +895,10 @@ export class MeilisearchService implements ISearchService {
`${eid.type}|${eid.sourceId ?? ''}|${eid.id}` satisfies MergedExternalId,
),
tags: season.tags,
countries: [],
collections: [],
audienceRating: null,
criticRating: null,
state: 'ok',
parent: {
id: encodeCaseSensitiveId(season.show.uuid),
@@ -1004,6 +1023,10 @@ export class MeilisearchService implements ISearchService {
`${eid.type}|${eid.sourceId ?? ''}|${eid.id}` satisfies MergedExternalId,
),
tags: artist.tags,
countries: [],
collections: [],
audienceRating: null,
criticRating: null,
state: 'ok',
};
@@ -1053,6 +1076,10 @@ export class MeilisearchService implements ISearchService {
`${eid.type}|${eid.sourceId ?? ''}|${eid.id}` satisfies MergedExternalId,
),
tags: album.tags,
countries: [],
collections: [],
audienceRating: null,
criticRating: null,
state: 'ok',
parent: {
id: encodeCaseSensitiveId(album.artist.uuid),
@@ -1566,6 +1593,33 @@ export class MeilisearchService implements ISearchService {
break;
}
let audienceRating: number | null;
let criticRating: number | null;
let countries: StringName[];
let collections: StringName[];
switch (program.type) {
case 'movie':
audienceRating = program.audienceRating ?? null;
criticRating = program.criticRating ?? null;
countries = program.countries ?? [];
collections = program.collections ?? [];
break;
case 'episode':
audienceRating = program.season?.show?.audienceRating ?? null;
criticRating = program.season?.show?.criticRating ?? null;
countries = program.season?.show?.countries ?? [];
collections = [];
break;
case 'track':
case 'other_video':
case 'music_video':
audienceRating = null;
criticRating = null;
countries = [];
collections = [];
break;
}
return {
id: program.uuid,
duration: program.duration ?? null,
@@ -1595,6 +1649,10 @@ export class MeilisearchService implements ISearchService {
writer: program.writers ?? [],
studio: program.studios ?? [],
tags: program.tags,
countries,
collections,
audienceRating,
criticRating,
mediaSourceId: encodeCaseSensitiveId(program.mediaSourceId),
libraryId: encodeCaseSensitiveId(program.libraryId),
videoWidth: width,

View File

@@ -62,6 +62,8 @@ const FactedStringFields = [
'video_dynamic_range',
'media_source_name',
'library_name',
'country',
'collection',
] as const;
const StringFields = [
@@ -69,6 +71,7 @@ const StringFields = [
'library_id',
'title',
'show_title',
'summary',
] as const;
const StringField = createToken({
@@ -95,6 +98,8 @@ const NumericFields = [
'audio_channels',
'release_year',
'year',
'audience_rating',
'critic_rating',
] as const;
const NumericField = createToken({
@@ -365,6 +370,11 @@ export const virtualFieldToIndexField: Record<string, string> = {
audio_channels: 'audioChannels',
// library_name: 'libraryName',
// media_source_name: 'mediaSourceName'
summary: 'summary',
country: 'countries.name',
collection: 'collections.name',
audience_rating: 'audienceRating',
critic_rating: 'criticRating',
};
export const indexFieldToVirtualField = invert(virtualFieldToIndexField, true);

View File

@@ -424,6 +424,7 @@ export const PlexMovieSchema = BasePlexMediaSchema.extend({
Media: z.array(PlexMediaDescriptionSchema).optional(),
Genre: z.array(PlexJoinItemSchema).optional(),
Country: z.array(PlexJoinItemSchema).optional(),
Collection: z.array(PlexJoinItemSchema).optional(),
Director: z.array(PlexJoinItemSchema).optional(),
Writer: z.array(PlexJoinItemSchema).optional(),
Role: z.array(PlexActorSchema).optional(),

View File

@@ -269,6 +269,10 @@ export const MovieMetadata = z.object({
...WithSummaryMetadata.shape,
type: z.literal('movie'),
rating: z.string().nullable(),
countries: z.array(NamedEntity).optional(),
collections: z.array(NamedEntity).optional(),
audienceRating: z.number().nullable().optional(),
criticRating: z.number().nullable().optional(),
});
export const Movie = z.object({
@@ -313,6 +317,10 @@ export const ShowMetadata = z.object({
releaseDate: z.number().nullable(),
releaseDateString: z.string().nullable(),
year: z.number().positive().nullable(),
countries: z.array(NamedEntity).optional(),
collections: z.array(NamedEntity).optional(),
audienceRating: z.number().nullable().optional(),
criticRating: z.number().nullable().optional(),
get seasons(): z.ZodOptional<z.ZodArray<typeof _SeasonWithTunarrMetadata>> {
return z.array(_SeasonWithTunarrMetadata).optional();
},

View File

@@ -244,6 +244,55 @@ export const SearchFieldSpecs: NonEmptyArray<
uiVisible: true,
visibleForLibraryTypes: 'all',
},
{
key: 'summary',
type: 'string' as const,
displayName: 'Summary',
uiVisible: true,
visibleForLibraryTypes: ['movies', 'shows'] as NoInfer<
ReadonlyArray<MediaSourceLibrary['mediaType']>
>,
} satisfies SearchFieldSpec<'string'>,
{
key: 'countries.name',
name: 'country',
type: 'faceted_string' as const,
displayName: 'Country',
uiVisible: true,
visibleForLibraryTypes: ['movies', 'shows'] as NoInfer<
ReadonlyArray<MediaSourceLibrary['mediaType']>
>,
} satisfies SearchFieldSpec<'faceted_string'>,
{
key: 'collections.name',
name: 'collection',
type: 'faceted_string' as const,
displayName: 'Collection',
uiVisible: true,
visibleForLibraryTypes: ['movies', 'shows'] as NoInfer<
ReadonlyArray<MediaSourceLibrary['mediaType']>
>,
} satisfies SearchFieldSpec<'faceted_string'>,
{
key: 'audienceRating',
name: 'audience_rating',
type: 'numeric' as const,
displayName: 'Audience Rating',
uiVisible: true,
visibleForLibraryTypes: ['movies', 'shows'] as NoInfer<
ReadonlyArray<MediaSourceLibrary['mediaType']>
>,
} satisfies SearchFieldSpec<'numeric'>,
{
key: 'criticRating',
name: 'critic_rating',
type: 'numeric' as const,
displayName: 'Critic Rating',
uiVisible: true,
visibleForLibraryTypes: ['movies', 'shows'] as NoInfer<
ReadonlyArray<MediaSourceLibrary['mediaType']>
>,
} satisfies SearchFieldSpec<'numeric'>,
];
interface Bij<In, Out = In> {