Files
mediahive/frontend/src/search-worker.ts
T
LeoVasanko 38f3ccebd3 Apply linters and formatters
- frontend: oxfmt across src, fix no-useless-escape in search-worker
- ruff check --fix: replace numeric rule codes with text names in
  pyproject.toml lint.ignore
- ruff format: scanning.py, guibuild.py, release.py
2026-09-24 00:26:53 +00:00

798 lines
24 KiB
TypeScript

// Search Web Worker - runs search off the main thread
// This file is loaded as a Web Worker, not imported as a module.
import type { MovieUi, SeriesUi, MatchedPerson, MatchedEpisode, SearchMatchInfo } from "./types"
// ---------------------------------------------------------------------------
// Message types
// ---------------------------------------------------------------------------
export interface SearchIndexMessage {
type: "index"
movies: MovieUi[]
series: SeriesUi[]
}
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: MovieUi[] = []
let series: SeriesUi[] = []
// ---------------------------------------------------------------------------
// 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: MovieUi, query: string): number {
const torrentFields: (string | null | undefined)[] = []
for (const torrent of Object.values(movie.files || {})) {
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: SeriesUi, 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.files || {})) {
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: MovieUi): SearchResultItem {
const files = Object.values(movie.files || {})
const resolution = files.length > 0 ? files[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: SeriesUi): 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?.collection,
),
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?.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
return
}
if (msg.type === "query") {
const { id, query } = msg
currentSearchId = id
const token: CancelToken = { id }
void (async () => {
const response = await performSearch(query, token)
if (!response) {
return
}
self.postMessage(response)
})()
}
}