Move frontend search to worker and improve people-name matching

This commit is contained in:
2026-05-26 20:43:10 +00:00
parent cf2b90809a
commit 4c33cee4ac
3 changed files with 883 additions and 493 deletions
+71 -493
View File
@@ -150,7 +150,7 @@
</section>
</template>
</template>
<template v-else>
<template v-else-if="!isSearching">
<!-- Empty hero area to maintain layout -->
<div class="empty-hero"></div>
<!-- Spacer for header overlay -->
@@ -172,8 +172,6 @@ import type {
Series,
MediaItem,
EpisodeWithSeries,
MatchedPerson,
MatchedEpisode,
TaskInfo,
} from "./types"
import {
@@ -191,6 +189,7 @@ import Header from "./components/Header.vue"
import CollageHero from "./components/CollageHero.vue"
import MediaRow from "./components/MediaRow.vue"
import MediaDetail from "./components/MediaDetail.vue"
import type { SearchResultItem, SearchResponseMessage } from "./search-worker"
// Initialize keyboard navigation
const { getFocusState, restoreFocusState, focusAt, focusElement } = useKeyboardNavigation()
@@ -370,19 +369,7 @@ function onGamepadAction(event: Event) {
// Focus episode info for navigating to series detail from search
const focusEpisode = ref<{ seasonNumber: number; episodeNumber: number } | null>(null)
// Search result categories
interface SearchCategory {
name: string
items: MediaItem[]
}
interface ScoredMediaItem {
item: MediaItem
score: number
matchType: "movies" | "series" | "people" | "other"
}
const searchCategories = ref<SearchCategory[]>([])
const searchCategories = ref<{ name: string; items: MediaItem[] }[]>([])
// Focus state per page for Escape navigation
const focusStateMap = new Map<string, { row: number; col: number }>()
@@ -901,278 +888,60 @@ const seriesByGenre = computed(() => {
return categories
})
// Calculate relevance score for a match
// Higher score = more relevant (beginning of name > word boundary > mid-word)
function normalizeSearchText(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim()
.replace(/\s+/g, " ")
}
// Search worker
let searchWorker: Worker | null = null
let pendingSearchId = 0
let workerHasIndex = false
function normalizePathSearchText(value: string): string {
return value
.toLowerCase()
.replace(/[\\/]+/g, "/")
.replace(/[^a-z0-9/]+/g, " ")
.trim()
.replace(/\s+/g, " ")
}
function getTermMatchScore(term: string, field: string): number {
const index = field.indexOf(term)
if (index < 0) return 0
if (index === 0) return 100
const charBefore = field[index - 1]
if (/\s/.test(charBefore)) return 80
if (index < field.length / 2) return 50
return 30
}
function getPathTermMatchScore(term: string, field: string): number {
const index = field.indexOf(term)
if (index < 0) return 0
if (index === 0) return 100
const charBefore = field[index - 1]
if (/\s|\//.test(charBefore)) return 80
if (index < field.length / 2) return 50
return 30
}
function getRelevanceScore(query: string, field: string): number {
const normalizedField = normalizeSearchText(field)
const normalizedQuery = normalizeSearchText(query)
if (!normalizedField || !normalizedQuery) return 0
let bestScore = 0
const exactIndex = normalizedField.indexOf(normalizedQuery)
// Exact phrase gets the strongest preference.
if (exactIndex >= 0) {
if (exactIndex === 0) {
bestScore = 110
} else {
const charBefore = normalizedField[exactIndex - 1]
if (/\s/.test(charBefore)) {
bestScore = 95
} else if (exactIndex < normalizedField.length / 2) {
bestScore = 75
} else {
bestScore = 60
}
function getSearchWorker(): Worker {
if (!searchWorker) {
searchWorker = new Worker(new URL("./search-worker.ts", import.meta.url), {
type: "module",
})
searchWorker.onmessage = (event: MessageEvent<SearchResponseMessage>) => {
const { id, results, categories } = event.data
// Ignore stale results
if (id !== pendingSearchId) return
searchResults.value = results.map(rehydrateSearchResult)
searchCategories.value = categories.map((cat) => ({
name: cat.name,
items: cat.items.map(rehydrateSearchResult),
}))
isSearching.value = false
}
}
const terms = normalizedQuery.split(" ")
if (terms.length > 1) {
let matchedTerms = 0
let termScoreTotal = 0
for (const term of terms) {
const termScore = getTermMatchScore(term, normalizedField)
if (termScore > 0) {
matchedTerms += 1
termScoreTotal += termScore
}
}
if (matchedTerms > 0) {
const coverage = matchedTerms / terms.length
const averageScore = termScoreTotal / matchedTerms
const combinedScore = Math.round(averageScore * (0.6 + coverage * 0.4))
if (combinedScore > bestScore) bestScore = combinedScore
}
}
return bestScore
return searchWorker
}
function getPathRelevanceScore(query: string, field: string): number {
const normalizedField = normalizePathSearchText(field)
const normalizedQuery = normalizePathSearchText(query)
if (!normalizedField || !normalizedQuery) return 0
let bestScore = 0
const exactIndex = normalizedField.indexOf(normalizedQuery)
if (exactIndex >= 0) {
if (exactIndex === 0) {
bestScore = 110
} else {
const charBefore = normalizedField[exactIndex - 1]
if (/\s|\//.test(charBefore)) {
bestScore = 95
} else if (exactIndex < normalizedField.length / 2) {
bestScore = 75
} else {
bestScore = 60
}
}
}
const terms = normalizedQuery.split(" ")
if (terms.length > 1) {
let matchedTerms = 0
let termScoreTotal = 0
for (const term of terms) {
const termScore = getPathTermMatchScore(term, normalizedField)
if (termScore > 0) {
matchedTerms += 1
termScoreTotal += termScore
}
}
if (matchedTerms > 0) {
const coverage = matchedTerms / terms.length
const averageScore = termScoreTotal / matchedTerms
const combinedScore = Math.round(averageScore * (0.6 + coverage * 0.4))
if (combinedScore > bestScore) bestScore = combinedScore
}
}
return bestScore
function syncWorkerIndex() {
if (!mediaIndex.value) return
const worker = getSearchWorker()
worker.postMessage({
type: "index",
movies: JSON.parse(JSON.stringify(mediaIndex.value.movies)),
series: JSON.parse(JSON.stringify(mediaIndex.value.series)),
})
workerHasIndex = true
}
// Get best relevance score from multiple fields
function getBestScore(query: string, ...fields: (string | null | undefined)[]): number {
let bestScore = 0
for (const field of fields) {
if (field) {
const score = getRelevanceScore(query, field)
if (score > bestScore) bestScore = score
}
// Rehydrate a lightweight SearchResultItem back into a full MediaItem
function rehydrateSearchResult(result: SearchResultItem): MediaItem {
const base: MediaItem = {
id: result.id,
title: result.title,
year: result.year,
cover_path: result.cover_path,
showreel_images: result.showreel_images,
showreel_source_sets: result.showreel_source_sets,
type: result.type,
resolution: result.resolution,
data: {} as Movie | Series, // placeholder; lookup on demand if needed
root_id: result.root_id,
searchMatchInfo: result.searchMatchInfo,
}
return bestScore
return base
}
function getMoviePathScore(movie: Movie, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const torrent of Object.values(movie.torrents || {})) {
torrentFields.push(torrent.title, torrent.playable_file)
}
let bestScore = 0
for (const field of torrentFields) {
if (!field) continue
const score = getPathRelevanceScore(query, field)
if (score > bestScore) bestScore = score
}
return bestScore
}
function getSeriesPathScore(series: Series, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
for (const torrent of Object.values(episode.torrents || {})) {
torrentFields.push(torrent.title, torrent.playable_file)
}
}
}
let bestScore = 0
for (const field of torrentFields) {
if (!field) continue
const score = getPathRelevanceScore(query, field)
if (score > bestScore) bestScore = score
}
return bestScore
}
// Check if any person name matches the query - returns matched people with roles
interface PersonMatch {
name: string
roles: string[]
highlightRoles: boolean // true if character name matched (vs actor name)
}
function matchesPeople(
query: string,
cast: { name: string; character?: string | null }[] | null | undefined,
director?: string | null,
creators?: string[] | null,
): { matches: PersonMatch[]; score: number } {
const matchedPeople: PersonMatch[] = []
let bestScore = 0
// Check director
if (director) {
const score = getRelevanceScore(query, director)
if (score > 0) {
matchedPeople.push({ name: director, roles: ["Director"], highlightRoles: false })
if (score > bestScore) bestScore = score
}
}
// Check creators
if (creators) {
for (const creator of creators) {
const score = getRelevanceScore(query, creator)
if (score > 0) {
const existing = matchedPeople.find((p) => p.name.toLowerCase() === creator.toLowerCase())
if (existing) {
if (!existing.roles.includes("Creator")) existing.roles.push("Creator")
} else {
matchedPeople.push({ name: creator, roles: ["Creator"], highlightRoles: false })
}
if (score > bestScore) bestScore = score
}
}
}
// Check cast - match on actor name or character name
if (cast) {
for (const person of cast) {
const nameScore = getRelevanceScore(query, person.name)
const characterScore = person.character ? getRelevanceScore(query, person.character) : 0
const bestPersonScore = Math.max(nameScore, characterScore)
if (bestPersonScore > 0) {
const role = person.character || "Cast"
const highlightRoles = characterScore > nameScore // Highlight character if that's what matched
const existing = matchedPeople.find(
(p) => p.name.toLowerCase() === person.name.toLowerCase(),
)
if (existing) {
if (!existing.roles.includes(role)) existing.roles.push(role)
// Update highlight if character matched better
if (highlightRoles) existing.highlightRoles = true
} else {
matchedPeople.push({ name: person.name, roles: [role], highlightRoles })
}
if (bestPersonScore > bestScore) bestScore = bestPersonScore
}
}
}
return { matches: matchedPeople, score: bestScore }
}
// Format matched people - returns array of MatchedPerson for display
function formatMatchedPeople(people: PersonMatch[]): MatchedPerson[] {
return people.map((p) => ({
name: p.name,
roles: p.roles.join(", "),
highlightRoles: p.highlightRoles,
}))
}
// Debounced search with limit
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const MAX_RESULTS = 100
// Watch for detail page entry/exit to manage focus
watch(selectedItem, (item, oldItem) => {
if (item && !oldItem) {
@@ -1218,232 +987,41 @@ watch(
{ immediate: true },
)
watch(searchQuery, (query) => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
function runSearch() {
const query = searchQuery.value
if (!query || !mediaIndex.value) {
searchResults.value = []
searchCategories.value = []
isSearching.value = false
pendingSearchId += 1
return
}
if (!workerHasIndex) {
syncWorkerIndex()
}
isSearching.value = true
pendingSearchId += 1
const id = pendingSearchId
// Debounce search by 50ms
searchTimeout = setTimeout(() => {
performSearch(query.toLowerCase())
}, 50)
})
function performSearch(query: string) {
if (!mediaIndex.value) return
const allScored: ScoredMediaItem[] = []
const processedIds = new Set<string>()
// Treat a standalone 4-digit query as a year hint, not an exclusive filter.
const yearMatch = query.match(/^(\d{4})$/)
const searchYear = yearMatch ? parseInt(yearMatch[1], 10) : null
const isYearQuery = searchYear !== null && searchYear >= 1900 && searchYear <= 2100
const yearBonus = 25
// Search movies
for (const movie of mediaIndex.value.movies) {
// Direct title match -> Movies category
const titleScore = getBestScore(query, movie.title, movie.info?.original_title)
const yearScore = isYearQuery && movie.year === searchYear ? yearBonus : 0
if (titleScore > 0 || yearScore > 0) {
allScored.push({
item: movieToMediaItem(movie),
score: titleScore + yearScore + (movie.info?.rating ?? 0) / 10,
matchType: "movies",
})
processedIds.add(movie.id)
continue
}
// Cast/director match -> People category
const peopleMatch = matchesPeople(query, movie.info?.cast, movie.info?.director)
if (peopleMatch.matches.length > 0) {
const item = movieToMediaItem(movie)
item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) }
allScored.push({
item,
score: peopleMatch.score + (movie.info?.rating ?? 0) / 10,
matchType: "people",
})
processedIds.add(movie.id)
continue
}
// Other metadata matches -> Other category
const otherScore = Math.max(
getBestScore(
query,
movie.info?.genres?.join(" "),
movie.info?.keywords?.join(" "),
movie.info?.overview,
movie.info?.tagline,
movie.info?.similar?.map((s) => s.title).join(" "),
),
getMoviePathScore(movie, query),
)
if (otherScore > 0) {
allScored.push({
item: movieToMediaItem(movie),
score: otherScore + (movie.info?.rating ?? 0) / 10,
matchType: "other",
})
processedIds.add(movie.id)
}
}
// Search series
for (const series of mediaIndex.value.series) {
// Direct title match -> Series category
const titleScore = getBestScore(query, series.title, series.info?.original_title)
// Extract year from release_date (format: "YYYY-MM-DD" or just "YYYY")
const seriesYear = series.info?.release_date
? parseInt(series.info.release_date.substring(0, 4), 10)
: null
const yearScore = isYearQuery && seriesYear === searchYear ? yearBonus : 0
if (titleScore > 0 || yearScore > 0) {
allScored.push({
item: seriesToMediaItem(series),
score: titleScore + yearScore + (series.info?.rating ?? 0) / 10,
matchType: "series",
})
processedIds.add(series.id)
continue
}
// Check episode name matches -> Series category (show the series with matched episodes)
const matchedEpisodes: MatchedEpisode[] = []
let episodeScore = 0
// Check if series has only one season and has ended (hide "SN" in that case)
const isEndedSingleSeason =
(series.info?.number_of_seasons === 1 || series.seasons?.length === 1) &&
["Ended", "Canceled", "Cancelled"].includes(series.info?.status || "")
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.name) {
const epScore = getRelevanceScore(query, episode.name)
if (epScore > 0) {
// Hide season for: single-season ended series OR Season 0 (specials)
const hideSeason = isEndedSingleSeason || season.season_number === 0
const location = hideSeason
? `Episode ${episode.episode_number}`
: `S${season.season_number} Episode ${episode.episode_number}`
matchedEpisodes.push({
name: episode.name,
location,
seasonNumber: season.season_number,
episodeNumber: episode.episode_number,
})
if (epScore > episodeScore) episodeScore = epScore
}
}
}
}
if (matchedEpisodes.length > 0 && !processedIds.has(series.id)) {
const item = seriesToMediaItem(series)
item.searchMatchInfo = { matchedEpisodes }
allScored.push({
item,
score: episodeScore + (series.info?.rating ?? 0) / 10,
matchType: "series",
})
processedIds.add(series.id)
continue
}
// Cast/creators match -> People category
const peopleMatch = matchesPeople(query, series.info?.cast, null, series.info?.creators)
if (peopleMatch.matches.length > 0 && !processedIds.has(series.id)) {
const item = seriesToMediaItem(series)
item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) }
allScored.push({
item,
score: peopleMatch.score + (series.info?.rating ?? 0) / 10,
matchType: "people",
})
processedIds.add(series.id)
continue
}
// Other metadata matches -> Other category
if (!processedIds.has(series.id)) {
const otherScore = Math.max(
getBestScore(
query,
series.info?.genres?.join(" "),
series.info?.keywords?.join(" "),
series.info?.overview,
series.info?.tagline,
series.info?.similar?.map((s) => s.title).join(" "),
series.info?.networks?.join(" "),
),
getSeriesPathScore(series, query),
)
if (otherScore > 0) {
allScored.push({
item: seriesToMediaItem(series),
score: otherScore + (series.info?.rating ?? 0) / 10,
matchType: "other",
})
processedIds.add(series.id)
}
}
}
// Sort all results by score (descending)
allScored.sort((a, b) => b.score - a.score)
// Take top results and deduplicate
const topResults = allScored.slice(0, MAX_RESULTS)
// Build categories from the scored results
const moviesCat: MediaItem[] = []
const seriesCat: MediaItem[] = []
const peopleCat: MediaItem[] = []
const otherCat: MediaItem[] = []
for (const scored of topResults) {
switch (scored.matchType) {
case "movies":
moviesCat.push(scored.item)
break
case "series":
seriesCat.push(scored.item)
break
case "people":
peopleCat.push(scored.item)
break
case "other":
otherCat.push(scored.item)
break
}
}
// Build categories array (only include non-empty)
const categories: SearchCategory[] = []
if (moviesCat.length > 0) categories.push({ name: "Movies", items: moviesCat })
if (seriesCat.length > 0) categories.push({ name: "Series", items: seriesCat })
if (peopleCat.length > 0) categories.push({ name: "People", items: peopleCat })
if (otherCat.length > 0) categories.push({ name: "Other", items: otherCat })
searchCategories.value = categories
// All results ranked by relevance for the hero
searchResults.value = topResults.map((s) => s.item)
isSearching.value = false
const worker = getSearchWorker()
worker.postMessage({
type: "query",
id,
query: query.toLowerCase(),
})
}
watch(searchQuery, runSearch)
watch(mediaIndex, () => {
workerHasIndex = false
if (searchQuery.value) {
syncWorkerIndex()
runSearch()
}
}, { deep: true })
// Sort by newest timestamp (descending)
function sortByNewest(items: MediaItem[]): MediaItem[] {
return [...items].sort((a, b) => {
+808
View File
@@ -0,0 +1,808 @@
// Search Web Worker - runs search off the main thread
// This file is loaded as a Web Worker, not imported as a module.
import type {
Movie,
Series,
MatchedPerson,
MatchedEpisode,
SearchMatchInfo,
} from "./types"
// ---------------------------------------------------------------------------
// Message types
// ---------------------------------------------------------------------------
export interface SearchIndexMessage {
type: "index"
movies: Movie[]
series: Series[]
}
export interface SearchQueryMessage {
type: "query"
id: number
query: string
}
export type SearchWorkerMessage = SearchIndexMessage | SearchQueryMessage
export interface SearchResultItem {
id: string
title: string | null
year?: number | null
cover_path: string | null
showreel_images?: string[] | null
showreel_source_sets?: string[][] | null
type: "movies" | "series"
resolution?: string | null
root_id: string | null
searchMatchInfo?: SearchMatchInfo
}
export interface SearchCategoryResult {
name: string
items: SearchResultItem[]
}
export interface SearchResponseMessage {
id: number
results: SearchResultItem[]
categories: SearchCategoryResult[]
}
// ---------------------------------------------------------------------------
// Worker state
// ---------------------------------------------------------------------------
let movies: Movie[] = []
let series: Series[] = []
// ---------------------------------------------------------------------------
// Normalization helpers (mirrored from App.vue)
// ---------------------------------------------------------------------------
function normalizeSearchText(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9\-]+/g, " ")
.trim()
.replace(/\s+/g, " ")
}
function normalizePathSearchText(value: string): string {
return value
.toLowerCase()
.replace(/[\\/]+/g, "/")
.replace(/[^a-z0-9/]+/g, " ")
.trim()
.replace(/\s+/g, " ")
}
// ---------------------------------------------------------------------------
// Scoring helpers (mirrored from App.vue)
// ---------------------------------------------------------------------------
function getTermMatchScore(term: string, field: string): number {
const index = field.indexOf(term)
if (index < 0) return 0
if (index === 0) return 100
const charBefore = field[index - 1]
if (/\s/.test(charBefore)) return 80
if (index < field.length / 2) return 50
return 30
}
function getPathTermMatchScore(term: string, field: string): number {
const index = field.indexOf(term)
if (index < 0) return 0
if (index === 0) return 100
const charBefore = field[index - 1]
if (/\s|\//.test(charBefore)) return 80
if (index < field.length / 2) return 50
return 30
}
function getRelevanceScore(query: string, field: string): number {
const normalizedField = normalizeSearchText(field)
const normalizedQuery = normalizeSearchText(query)
if (!normalizedField || !normalizedQuery) return 0
let bestScore = 0
const exactIndex = normalizedField.indexOf(normalizedQuery)
if (exactIndex >= 0) {
if (exactIndex === 0) {
bestScore = 110
} else {
const charBefore = normalizedField[exactIndex - 1]
if (/\s/.test(charBefore)) {
bestScore = 95
} else if (exactIndex < normalizedField.length / 2) {
bestScore = 75
} else {
bestScore = 60
}
}
}
const terms = normalizedQuery.split(" ")
if (terms.length > 1) {
let matchedTerms = 0
let termScoreTotal = 0
for (const term of terms) {
const termScore = getTermMatchScore(term, normalizedField)
if (termScore > 0) {
matchedTerms += 1
termScoreTotal += termScore
}
}
if (matchedTerms > 0) {
const coverage = matchedTerms / terms.length
const averageScore = termScoreTotal / matchedTerms
const combinedScore = Math.round(averageScore * (0.6 + coverage * 0.4))
if (combinedScore > bestScore) bestScore = combinedScore
}
}
return bestScore
}
function getPathRelevanceScore(query: string, field: string): number {
const normalizedField = normalizePathSearchText(field)
const normalizedQuery = normalizePathSearchText(query)
if (!normalizedField || !normalizedQuery) return 0
let bestScore = 0
const exactIndex = normalizedField.indexOf(normalizedQuery)
if (exactIndex >= 0) {
if (exactIndex === 0) {
bestScore = 110
} else {
const charBefore = normalizedField[exactIndex - 1]
if (/\s|\//.test(charBefore)) {
bestScore = 95
} else if (exactIndex < normalizedField.length / 2) {
bestScore = 75
} else {
bestScore = 60
}
}
}
const terms = normalizedQuery.split(" ")
if (terms.length > 1) {
let matchedTerms = 0
let termScoreTotal = 0
for (const term of terms) {
const termScore = getPathTermMatchScore(term, normalizedField)
if (termScore > 0) {
matchedTerms += 1
termScoreTotal += termScore
}
}
if (matchedTerms > 0) {
const coverage = matchedTerms / terms.length
const averageScore = termScoreTotal / matchedTerms
const combinedScore = Math.round(averageScore * (0.6 + coverage * 0.4))
if (combinedScore > bestScore) bestScore = combinedScore
}
}
return bestScore
}
function getBestScore(query: string, ...fields: (string | null | undefined)[]): number {
let bestScore = 0
for (const field of fields) {
if (field) {
const score = getRelevanceScore(query, field)
if (score > bestScore) bestScore = score
}
}
return bestScore
}
function getMoviePathScore(movie: Movie, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const torrent of Object.values(movie.torrents || {})) {
torrentFields.push(torrent.title, torrent.playable_file)
}
let bestScore = 0
for (const field of torrentFields) {
if (!field) continue
const score = getPathRelevanceScore(query, field)
if (score > bestScore) bestScore = score
}
return bestScore
}
function getSeriesPathScore(series: Series, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
for (const torrent of Object.values(episode.torrents || {})) {
torrentFields.push(torrent.title, torrent.playable_file)
}
}
}
let bestScore = 0
for (const field of torrentFields) {
if (!field) continue
const score = getPathRelevanceScore(query, field)
if (score > bestScore) bestScore = score
}
return bestScore
}
// ---------------------------------------------------------------------------
// People matching (mirrored from App.vue)
// ---------------------------------------------------------------------------
interface PersonMatch {
name: string
roles: string[]
highlightRoles: boolean
}
interface PersonCandidate {
name: string
role: string
highlightRoles: boolean
score: number
matchedWordIndexes: number[]
}
function getQueryWords(value: string): string[] {
const normalized = normalizeSearchText(value)
if (!normalized) return []
return normalized.split(" ")
}
function getMatchedWordIndexes(queryWords: string[], value: string): number[] {
const normalized = normalizeSearchText(value)
if (!normalized || queryWords.length === 0) return []
const targetWords = normalized.split(" ")
const matches: number[] = []
for (let i = 0; i < queryWords.length; i += 1) {
const queryWord = queryWords[i]
if (targetWords.some((targetWord) => targetWord.startsWith(queryWord))) {
matches.push(i)
}
}
return matches
}
function mergePersonMatch(target: PersonMatch[], candidate: PersonCandidate): void {
const existing = target.find((person) => person.name.toLowerCase() === candidate.name.toLowerCase())
if (existing) {
if (!existing.roles.includes(candidate.role)) existing.roles.push(candidate.role)
if (candidate.highlightRoles) existing.highlightRoles = true
return
}
target.push({
name: candidate.name,
roles: [candidate.role],
highlightRoles: candidate.highlightRoles,
})
}
function getBestContiguousWordRun(indexes: number[], availableIndexes: Set<number>): number[] {
const sorted = indexes
.filter((index) => availableIndexes.has(index))
.sort((a, b) => a - b)
if (sorted.length === 0) return []
let bestStart = 0
let bestLength = 1
let runStart = 0
let runLength = 1
for (let i = 1; i < sorted.length; i += 1) {
if (sorted[i] === sorted[i - 1] + 1) {
runLength += 1
continue
}
if (runLength > bestLength) {
bestStart = runStart
bestLength = runLength
}
runStart = i
runLength = 1
}
if (runLength > bestLength) {
bestStart = runStart
bestLength = runLength
}
return sorted.slice(bestStart, bestStart + bestLength)
}
function nameMatchesQuery(query: string, name: string): boolean {
const nq = normalizeSearchText(query)
const nn = normalizeSearchText(name)
if (!nq || !nn) return false
// Exact substring match (e.g. "jackie chan" matches "jackie chan" and "jackie chans")
if (nn.includes(nq)) return true
// Each query word must appear as a prefix of a name word (word-boundary match)
const queryWords = nq.split(" ")
const nameWords = nn.split(" ")
return queryWords.every((qw) => nameWords.some((nw) => nw.startsWith(qw)))
}
function getNameMatchScore(query: string, name: string): number {
if (!nameMatchesQuery(query, name)) return 0
const nq = normalizeSearchText(query)
const nn = normalizeSearchText(name)
const idx = nn.indexOf(nq)
if (idx === 0) return 110
if (idx > 0) {
const before = nn[idx - 1]
if (/\s/.test(before)) return 95
return 75
}
// Prefix-based match: score lower than exact substring
return 70
}
function matchesPeople(
query: string,
cast: { name: string; character?: string | null }[] | null | undefined,
director?: string | null,
creators?: string[] | null,
): { matches: PersonMatch[]; score: number } {
const matchedPeople: PersonMatch[] = []
const candidates: PersonCandidate[] = []
const queryWords = getQueryWords(query)
const addCandidate = (
name: string,
role: string,
highlightRoles: boolean,
score: number,
matchedWordIndexes: number[],
) => {
candidates.push({ name, role, highlightRoles, score, matchedWordIndexes })
}
let bestScore = 0
if (director) {
const score = getNameMatchScore(query, director)
if (score > 0) {
addCandidate(director, "Director", false, score, getMatchedWordIndexes(queryWords, director))
} else {
addCandidate(director, "Director", false, 0, getMatchedWordIndexes(queryWords, director))
}
}
if (creators) {
for (const creator of creators) {
const score = getNameMatchScore(query, creator)
if (score > 0) {
addCandidate(creator, "Creator", false, score, getMatchedWordIndexes(queryWords, creator))
} else {
addCandidate(creator, "Creator", false, 0, getMatchedWordIndexes(queryWords, creator))
}
}
}
if (cast) {
for (const person of cast) {
const nameScore = getNameMatchScore(query, person.name)
const characterScore = person.character ? getNameMatchScore(query, person.character) : 0
const bestPersonScore = Math.max(nameScore, characterScore)
const nameWordIndexes = getMatchedWordIndexes(queryWords, person.name)
const characterWordIndexes = person.character
? getMatchedWordIndexes(queryWords, person.character)
: []
const useCharacterWords = characterWordIndexes.length > nameWordIndexes.length
const matchedWordIndexes = useCharacterWords ? characterWordIndexes : nameWordIndexes
if (bestPersonScore > 0) {
const role = person.character || "Cast"
const highlightRoles = characterScore > nameScore
addCandidate(person.name, role, highlightRoles, bestPersonScore, matchedWordIndexes)
} else {
addCandidate(person.name, person.character || "Cast", false, 0, matchedWordIndexes)
}
}
}
for (const candidate of candidates) {
if (candidate.score <= 0) continue
mergePersonMatch(matchedPeople, candidate)
if (candidate.score > bestScore) bestScore = candidate.score
}
if (matchedPeople.length > 0) {
return { matches: matchedPeople, score: bestScore }
}
if (queryWords.some((word) => word.length < 2)) {
return { matches: [], score: 0 }
}
const explicitMultiPerson = /[,&+]/.test(query)
const uncoveredWordIndexes = new Set(queryWords.map((_, index) => index))
const selected: Array<{ candidate: PersonCandidate; matchedIndexes: number[] }> = []
const usableCandidates = candidates.filter((candidate) => candidate.matchedWordIndexes.length > 0)
let hasMultiWordChunk = false
while (uncoveredWordIndexes.size > 0) {
let bestCandidate: PersonCandidate | null = null
let bestChunk: number[] = []
let bestCoverage = 0
for (const candidate of usableCandidates) {
if (selected.some((entry) => entry.candidate === candidate)) continue
const chunk = getBestContiguousWordRun(candidate.matchedWordIndexes, uncoveredWordIndexes)
if (chunk.length <= 0) continue
const coverage = candidate.matchedWordIndexes.length
if (
chunk.length > bestChunk.length ||
(chunk.length === bestChunk.length && coverage > bestCoverage)
) {
bestCandidate = candidate
bestChunk = chunk
bestCoverage = coverage
}
}
if (!bestCandidate || bestChunk.length <= 0) break
selected.push({ candidate: bestCandidate, matchedIndexes: bestChunk })
if (bestChunk.length > 1) hasMultiWordChunk = true
for (const index of bestChunk) {
uncoveredWordIndexes.delete(index)
}
}
if (uncoveredWordIndexes.size > 0 || selected.length === 0) {
return { matches: [], score: 0 }
}
// Without explicit separators, require at least one multi-word person chunk.
// This avoids accidental matches like "jackie chan" => "Jackie" + "Chan".
if (!explicitMultiPerson && selected.length > 1 && !hasMultiWordChunk) {
return { matches: [], score: 0 }
}
let multiPersonScore = 0
for (const entry of selected) {
mergePersonMatch(matchedPeople, entry.candidate)
const candidateScore = entry.candidate.score > 0 ? entry.candidate.score : 65
if (candidateScore > multiPersonScore) multiPersonScore = candidateScore
}
return { matches: matchedPeople, score: multiPersonScore }
}
function formatMatchedPeople(people: PersonMatch[]): MatchedPerson[] {
return people.map((p) => ({
name: p.name,
roles: p.roles.join(", "),
highlightRoles: p.highlightRoles,
}))
}
// ---------------------------------------------------------------------------
// MediaItem conversion (lightweight, without data payload)
// ---------------------------------------------------------------------------
function movieToSearchResult(movie: Movie): SearchResultItem {
const torrents = Object.values(movie.torrents || {})
const resolution = torrents.length > 0 ? torrents[0].resolution : null
return {
id: movie.id,
title: movie.title || "Unknown",
year: movie.year,
cover_path: movie.cover_path,
showreel_images: movie.showreel_images,
showreel_source_sets: movie.showreel_source_sets,
type: "movies",
resolution,
root_id: movie.root_id,
}
}
function seriesToSearchResult(series: Series): SearchResultItem {
const reelImages: string[] = []
const reelSourceSets: string[][] = []
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.reel_sources && episode.reel_sources.length > 0) {
reelImages.push(episode.reel_sources[0])
reelSourceSets.push(episode.reel_sources)
} else if (episode.reel_image) {
reelImages.push(episode.reel_image)
reelSourceSets.push([episode.reel_image])
}
}
}
return {
id: series.id,
title: series.title || "Unknown",
year: null,
cover_path: series.cover_path,
showreel_images: reelImages.length > 0 ? reelImages : null,
showreel_source_sets: reelSourceSets.length > 0 ? reelSourceSets : null,
type: "series",
root_id: series.root_id,
}
}
// ---------------------------------------------------------------------------
// Core search with cancellation token
// ---------------------------------------------------------------------------
const MAX_RESULTS = 100
interface ScoredResult {
item: SearchResultItem
score: number
matchType: "movies" | "series" | "people" | "other"
}
interface CancelToken {
id: number
}
let currentSearchId = 0
function isCancelled(token: CancelToken): boolean {
return token.id !== currentSearchId
}
/** Yield control briefly so the worker can receive a new message. */
function yieldControl(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
async function performSearch(
query: string,
token: CancelToken,
): Promise<SearchResponseMessage | null> {
const allScored: ScoredResult[] = []
const processedIds = new Set<string>()
const yearMatch = query.match(/^(\d{4})$/)
const searchYear = yearMatch ? parseInt(yearMatch[1], 10) : null
const isYearQuery = searchYear !== null && searchYear >= 1900 && searchYear <= 2100
const yearBonus = 25
// -------------------------------------------------------------------------
// Search movies
// -------------------------------------------------------------------------
for (let i = 0; i < movies.length; i++) {
if (i % 50 === 0) {
if (isCancelled(token)) return null
await yieldControl()
}
const movie = movies[i]
const titleScore = getBestScore(query, movie.title, movie.info?.original_title)
const yearScore = isYearQuery && movie.year === searchYear ? yearBonus : 0
if (titleScore > 0 || yearScore > 0) {
allScored.push({
item: movieToSearchResult(movie),
score: titleScore + yearScore + (movie.info?.rating ?? 0) / 10,
matchType: "movies",
})
processedIds.add(movie.id)
continue
}
const peopleMatch = matchesPeople(query, movie.info?.cast, movie.info?.director)
if (peopleMatch.matches.length > 0) {
const item = movieToSearchResult(movie)
item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) }
allScored.push({
item,
score: peopleMatch.score + (movie.info?.rating ?? 0) / 10,
matchType: "people",
})
processedIds.add(movie.id)
continue
}
const otherScore = Math.max(
getBestScore(
query,
movie.info?.genres?.join(" "),
movie.info?.keywords?.join(" "),
movie.info?.overview,
movie.info?.tagline,
movie.info?.similar?.map((s) => s.title).join(" "),
),
getMoviePathScore(movie, query),
)
if (otherScore > 0) {
allScored.push({
item: movieToSearchResult(movie),
score: otherScore + (movie.info?.rating ?? 0) / 10,
matchType: "other",
})
processedIds.add(movie.id)
}
}
// -------------------------------------------------------------------------
// Search series
// -------------------------------------------------------------------------
for (let i = 0; i < series.length; i++) {
if (i % 50 === 0) {
if (isCancelled(token)) return null
await yieldControl()
}
const seriesItem = series[i]
const titleScore = getBestScore(query, seriesItem.title, seriesItem.info?.original_title)
const seriesYear = seriesItem.info?.release_date
? parseInt(seriesItem.info.release_date.substring(0, 4), 10)
: null
const yearScore = isYearQuery && seriesYear === searchYear ? yearBonus : 0
if (titleScore > 0 || yearScore > 0) {
allScored.push({
item: seriesToSearchResult(seriesItem),
score: titleScore + yearScore + (seriesItem.info?.rating ?? 0) / 10,
matchType: "series",
})
processedIds.add(seriesItem.id)
continue
}
const matchedEpisodes: MatchedEpisode[] = []
let episodeScore = 0
const isEndedSingleSeason =
(seriesItem.info?.number_of_seasons === 1 || seriesItem.seasons?.length === 1) &&
["Ended", "Canceled", "Cancelled"].includes(seriesItem.info?.status || "")
for (const season of seriesItem.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.name) {
const epScore = getRelevanceScore(query, episode.name)
if (epScore > 0) {
const hideSeason = isEndedSingleSeason || season.season_number === 0
const location = hideSeason
? `Episode ${episode.episode_number}`
: `S${season.season_number} Episode ${episode.episode_number}`
matchedEpisodes.push({
name: episode.name,
location,
seasonNumber: season.season_number,
episodeNumber: episode.episode_number,
})
if (epScore > episodeScore) episodeScore = epScore
}
}
}
}
if (matchedEpisodes.length > 0 && !processedIds.has(seriesItem.id)) {
const item = seriesToSearchResult(seriesItem)
item.searchMatchInfo = { matchedEpisodes }
allScored.push({
item,
score: episodeScore + (seriesItem.info?.rating ?? 0) / 10,
matchType: "series",
})
processedIds.add(seriesItem.id)
continue
}
const peopleMatch = matchesPeople(query, seriesItem.info?.cast, null, seriesItem.info?.creators)
if (peopleMatch.matches.length > 0 && !processedIds.has(seriesItem.id)) {
const item = seriesToSearchResult(seriesItem)
item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) }
allScored.push({
item,
score: peopleMatch.score + (seriesItem.info?.rating ?? 0) / 10,
matchType: "people",
})
processedIds.add(seriesItem.id)
continue
}
if (!processedIds.has(seriesItem.id)) {
const otherScore = Math.max(
getBestScore(
query,
seriesItem.info?.genres?.join(" "),
seriesItem.info?.keywords?.join(" "),
seriesItem.info?.overview,
seriesItem.info?.tagline,
seriesItem.info?.similar?.map((s) => s.title).join(" "),
seriesItem.info?.networks?.join(" "),
),
getSeriesPathScore(seriesItem, query),
)
if (otherScore > 0) {
allScored.push({
item: seriesToSearchResult(seriesItem),
score: otherScore + (seriesItem.info?.rating ?? 0) / 10,
matchType: "other",
})
processedIds.add(seriesItem.id)
}
}
}
if (isCancelled(token)) return null
allScored.sort((a, b) => b.score - a.score)
const topResults = allScored.slice(0, MAX_RESULTS)
const moviesCat: SearchResultItem[] = []
const seriesCat: SearchResultItem[] = []
const peopleCat: SearchResultItem[] = []
const otherCat: SearchResultItem[] = []
for (const scored of topResults) {
switch (scored.matchType) {
case "movies":
moviesCat.push(scored.item)
break
case "series":
seriesCat.push(scored.item)
break
case "people":
peopleCat.push(scored.item)
break
case "other":
otherCat.push(scored.item)
break
}
}
const categories: SearchCategoryResult[] = []
if (moviesCat.length > 0) categories.push({ name: "Movies", items: moviesCat })
if (seriesCat.length > 0) categories.push({ name: "Series", items: seriesCat })
if (peopleCat.length > 0) categories.push({ name: "People", items: peopleCat })
if (otherCat.length > 0) categories.push({ name: "Other", items: otherCat })
return {
id: token.id,
results: topResults.map((s) => s.item),
categories,
}
}
// ---------------------------------------------------------------------------
// Worker message handler — single persistent runner
// ---------------------------------------------------------------------------
self.onmessage = (event: MessageEvent<SearchWorkerMessage>) => {
const msg = event.data
if (msg.type === "index") {
movies = msg.movies
series = msg.series
console.log("[worker] index updated movies=%d series=%d", movies.length, series.length)
return
}
if (msg.type === "query") {
const { id, query } = msg
console.log("[worker] received query id=%d query=%q", id, query)
currentSearchId = id
const token: CancelToken = { id }
void (async () => {
const response = await performSearch(query, token)
if (response === null) {
console.log("[worker] search id=%d query=%q CANCELLED", id, query)
return
}
console.log("[worker] posting result id=%d query=%q results=%d categories=%d", id, query, response.results.length, response.categories.length)
self.postMessage(response)
})()
}
}