Move frontend search to worker and improve people-name matching
This commit is contained in:
+71
-493
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
})()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user