pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add fuzzy search

+79 -3
+79 -3
src/backend/metadata/search.ts
··· 1 + import Fuse from "fuse.js"; 2 + 1 3 import { SimpleCache } from "@/utils/cache"; 2 4 import { MediaItem } from "@/utils/mediaTypes"; 3 5 ··· 8 10 getMediaPoster, 9 11 multiSearch, 10 12 } from "./tmdb"; 11 - import { TMDBContentTypes } from "./types/tmdb"; 13 + import { 14 + TMDBContentTypes, 15 + TMDBMovieSearchResult, 16 + TMDBShowSearchResult, 17 + } from "./types/tmdb"; 12 18 13 19 export interface MWQuery { 14 20 searchQuery: string; ··· 22 28 23 29 // detect "tmdb:123456" or "tmdb:123456:movie" or "tmdb:123456:tv" 24 30 const tmdbIdPattern = /^tmdb:(\d+)(?::(movie|tv))?$/i; 31 + const trailingYearPattern = /\s+\b(19|20)\d{2}\b$/; 32 + 33 + function normalizeQuery(input: string): string { 34 + return input 35 + .toLowerCase() 36 + .replace(/[^\p{L}\p{N}\s]/gu, " ") 37 + .replace(/\s+/g, " ") 38 + .trim(); 39 + } 40 + 41 + function getLenientQueries(searchQuery: string): string[] { 42 + const base = searchQuery.trim(); 43 + const normalized = normalizeQuery(base); 44 + const withoutTrailingYear = base.replace(trailingYearPattern, "").trim(); 45 + const normalizedWithoutYear = normalizeQuery(withoutTrailingYear); 46 + 47 + return [ 48 + ...new Set([base, normalized, withoutTrailingYear, normalizedWithoutYear]), 49 + ].filter((q) => q.length > 0); 50 + } 51 + 52 + function dedupeTMDBResults( 53 + items: (TMDBMovieSearchResult | TMDBShowSearchResult)[], 54 + ): (TMDBMovieSearchResult | TMDBShowSearchResult)[] { 55 + const deduped = new Map< 56 + string, 57 + TMDBMovieSearchResult | TMDBShowSearchResult 58 + >(); 59 + 60 + items.forEach((item) => { 61 + deduped.set(`${item.media_type}:${item.id}`, item); 62 + }); 63 + 64 + return Array.from(deduped.values()); 65 + } 66 + 67 + function rankTMDBResultsFuzzy( 68 + items: (TMDBMovieSearchResult | TMDBShowSearchResult)[], 69 + query: string, 70 + ): (TMDBMovieSearchResult | TMDBShowSearchResult)[] { 71 + if (items.length <= 1) return items; 72 + 73 + const fuse = new Fuse(items, { 74 + includeScore: true, 75 + ignoreLocation: true, 76 + threshold: 0.45, 77 + minMatchCharLength: 2, 78 + keys: [ 79 + { name: "title", weight: 0.6 }, 80 + { name: "name", weight: 0.6 }, 81 + { name: "original_title", weight: 0.2 }, 82 + { name: "original_name", weight: 0.2 }, 83 + ], 84 + }); 85 + 86 + const ranked = fuse.search(query).map((result) => result.item); 87 + const rankedSet = new Set( 88 + ranked.map((item) => `${item.media_type}:${item.id}`), 89 + ); 90 + const remainder = items.filter( 91 + (item) => !rankedSet.has(`${item.media_type}:${item.id}`), 92 + ); 93 + 94 + return ranked.concat(remainder); 95 + } 25 96 26 97 export async function searchForMedia(query: MWQuery): Promise<MediaItem[]> { 27 98 if (cache.has(query)) return cache.get(query) as MediaItem[]; ··· 69 140 } 70 141 } 71 142 72 - const data = await multiSearch(searchQuery); 143 + const queryVariants = getLenientQueries(searchQuery); 144 + const resultSets = await Promise.all( 145 + queryVariants.map((q) => multiSearch(q)), 146 + ); 147 + const data = dedupeTMDBResults(resultSets.flat()); 148 + const rankedData = rankTMDBResultsFuzzy(data, searchQuery); 73 149 74 - const results = data.map((v) => { 150 + const results = rankedData.map((v) => { 75 151 const formattedResult = formatTMDBSearchResult(v, v.media_type); 76 152 return formatTMDBMetaToMediaItem(formattedResult); 77 153 });