export type QuerySortOrder = "asc" | "desc"; export type QuerySortRelevanceScope = "movie" | "series" | "episode" | "all"; export type QuerySortField = | "title" | "added_at" | "release_date" | "last_air_date" | "year" | "content_rating" | "runtime" | "rating_imdb" | "rating_tmdb" | "rating_rt_critic" | "rating_rt_audience" | "resolution" | "bitrate" | "progress" | "date_viewed" | "plays"; type ApplicableMediaScope = Exclude; export interface QuerySortOption { value: QuerySortField; label: string; defaultOrder: QuerySortOrder; personalized: boolean; applicableMediaScopes: ApplicableMediaScope[]; /** * When set, the wizard's scope selector switches to this scope * if the user picks this sort in a different scope. Used for sorts * whose semantics imply a particular media type (e.g. Latest Episode * Air Date is most meaningful when results are episodes). */ preferredMediaScope?: ApplicableMediaScope; } export interface QuerySortOptionsConfig { includePersonalized?: boolean; relevanceScope?: QuerySortRelevanceScope; } type QuerySortOptionsInput = boolean | QuerySortOptionsConfig; interface QuerySortLike { field?: string | null; order?: string | null; } const ALL_MEDIA_SCOPES: ApplicableMediaScope[] = ["movie", "series", "episode"]; export const QUERY_SORT_OPTIONS: QuerySortOption[] = [ { value: "title", label: "Title", defaultOrder: "asc", personalized: false, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "added_at", label: "Date Added", defaultOrder: "desc", personalized: false, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "release_date", label: "Release Date", defaultOrder: "desc", personalized: false, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "last_air_date", label: "Latest Episode Air Date", defaultOrder: "desc", personalized: false, applicableMediaScopes: ["series", "episode"], preferredMediaScope: "episode", }, { value: "year", label: "Year", defaultOrder: "desc", personalized: false, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "content_rating", label: "Content Rating", defaultOrder: "asc", personalized: false, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "runtime", label: "Duration", defaultOrder: "desc", personalized: false, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "rating_imdb", label: "IMDb Rating", defaultOrder: "desc", personalized: false, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "rating_tmdb", label: "TMDB Rating", defaultOrder: "desc", personalized: false, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "rating_rt_critic", label: "RT Critic Rating", defaultOrder: "desc", personalized: false, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "rating_rt_audience", label: "RT Audience Rating", defaultOrder: "desc", personalized: false, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "resolution", label: "Resolution", defaultOrder: "desc", personalized: false, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "bitrate", label: "Bitrate", defaultOrder: "desc", personalized: false, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "progress", label: "Progress", defaultOrder: "desc", personalized: true, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "date_viewed", label: "Date Viewed", defaultOrder: "desc", personalized: true, applicableMediaScopes: ALL_MEDIA_SCOPES, }, { value: "plays", label: "Plays", defaultOrder: "desc", personalized: true, applicableMediaScopes: ALL_MEDIA_SCOPES, }, ]; const QUERY_SORT_OPTION_MAP = new Map(QUERY_SORT_OPTIONS.map((option) => [option.value, option])); function normalizeQuerySortOptionsConfig( input: QuerySortOptionsInput = false, ): QuerySortOptionsConfig { if (typeof input === "boolean") { return { includePersonalized: input }; } return input; } function optionMatchesRelevanceScope( option: QuerySortOption, relevanceScope?: QuerySortRelevanceScope, ): boolean { if (!relevanceScope) { return true; } if (relevanceScope === "all") { return ALL_MEDIA_SCOPES.every((scope) => option.applicableMediaScopes.includes(scope)); } return option.applicableMediaScopes.includes(relevanceScope); } export function getQuerySortOptions(input: QuerySortOptionsInput = false): QuerySortOption[] { const { includePersonalized = false, relevanceScope } = normalizeQuerySortOptionsConfig(input); return QUERY_SORT_OPTIONS.filter( (option) => (includePersonalized || !option.personalized) && optionMatchesRelevanceScope(option, relevanceScope), ); } export function normalizeQuerySortForScope( sort?: QuerySortLike | null, input: QuerySortOptionsInput = false, ): { field: QuerySortField; order: QuerySortOrder } { const sortOptions = getQuerySortOptions(input); const normalizedField = normalizeQuerySortField(sort?.field); if (normalizedField && normalizedField !== "relevance") { const matchingOption = sortOptions.find((option) => option.value === normalizedField); if (matchingOption) { return { field: matchingOption.value, order: sort?.order === "asc" || sort?.order === "desc" ? sort.order : matchingOption.defaultOrder, }; } } const fallbackOption = sortOptions[0] ?? QUERY_SORT_OPTION_MAP.get("added_at"); if (!fallbackOption) { return { field: "added_at", order: "desc" }; } return { field: fallbackOption.value, order: fallbackOption.defaultOrder, }; } export function normalizeQuerySortField( field?: string | null, ): QuerySortField | "relevance" | undefined { const normalized = field?.trim().toLowerCase(); switch (normalized) { case undefined: return undefined; case "": return undefined; case "sort_title": return "title"; case "recently_added": return "added_at"; case "rating": return "rating_imdb"; case "relevance": return "relevance"; default: return QUERY_SORT_OPTION_MAP.has(normalized as QuerySortField) ? (normalized as QuerySortField) : undefined; } } export function getDefaultQuerySortOrder(field?: string | null): QuerySortOrder { const normalized = normalizeQuerySortField(field); if (!normalized || normalized === "relevance") { return "desc"; } return QUERY_SORT_OPTION_MAP.get(normalized)?.defaultOrder ?? "desc"; } export function isPersonalizedQuerySortField(field?: string | null): boolean { const normalized = normalizeQuerySortField(field); return normalized != null && normalized !== "relevance" ? (QUERY_SORT_OPTION_MAP.get(normalized)?.personalized ?? false) : false; }