Improve media search ranking
This commit is contained in:
+73
-47
@@ -749,28 +749,77 @@ const seriesByGenre = computed(() => {
|
|||||||
|
|
||||||
// Calculate relevance score for a match
|
// Calculate relevance score for a match
|
||||||
// Higher score = more relevant (beginning of name > word boundary > mid-word)
|
// Higher score = more relevant (beginning of name > word boundary > mid-word)
|
||||||
function getRelevanceScore(query: string, field: string): number {
|
function normalizeSearchText(value: string): string {
|
||||||
const lowerField = field.toLowerCase();
|
return value
|
||||||
const lowerQuery = query.toLowerCase();
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
if (!lowerField.includes(lowerQuery)) return 0;
|
function getTermMatchScore(term: string, field: string): number {
|
||||||
|
const index = field.indexOf(term);
|
||||||
|
if (index < 0) return 0;
|
||||||
|
|
||||||
const index = lowerField.indexOf(lowerQuery);
|
|
||||||
|
|
||||||
// Exact match at start of string - highest score
|
|
||||||
if (index === 0) return 100;
|
if (index === 0) return 100;
|
||||||
|
|
||||||
// Match at word boundary (after space, hyphen, colon, etc.)
|
const charBefore = field[index - 1];
|
||||||
const charBefore = lowerField[index - 1];
|
if (/\s/.test(charBefore)) return 80;
|
||||||
if (/[\s\-:_.,;()\[\]]/.test(charBefore)) return 80;
|
|
||||||
|
|
||||||
// Match in the first half of the string
|
if (index < field.length / 2) return 50;
|
||||||
if (index < lowerField.length / 2) return 50;
|
|
||||||
|
|
||||||
// Match anywhere else
|
|
||||||
return 30;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Get best relevance score from multiple fields
|
// Get best relevance score from multiple fields
|
||||||
function getBestScore(query: string, ...fields: (string | null | undefined)[]): number {
|
function getBestScore(query: string, ...fields: (string | null | undefined)[]): number {
|
||||||
let bestScore = 0;
|
let bestScore = 0;
|
||||||
@@ -915,32 +964,21 @@ function performSearch(query: string) {
|
|||||||
const allScored: ScoredMediaItem[] = [];
|
const allScored: ScoredMediaItem[] = [];
|
||||||
const processedIds = new Set<string>();
|
const processedIds = new Set<string>();
|
||||||
|
|
||||||
// Check if query is a year (4 digits, reasonable range)
|
// Treat a standalone 4-digit query as a year hint, not an exclusive filter.
|
||||||
const yearMatch = query.match(/^(\d{4})$/);
|
const yearMatch = query.match(/^(\d{4})$/);
|
||||||
const searchYear = yearMatch ? parseInt(yearMatch[1], 10) : null;
|
const searchYear = yearMatch ? parseInt(yearMatch[1], 10) : null;
|
||||||
const isYearSearch = searchYear !== null && searchYear >= 1900 && searchYear <= 2100;
|
const isYearQuery = searchYear !== null && searchYear >= 1900 && searchYear <= 2100;
|
||||||
|
const yearBonus = 25;
|
||||||
|
|
||||||
// Search movies
|
// Search movies
|
||||||
for (const movie of mediaIndex.value.movies) {
|
for (const movie of mediaIndex.value.movies) {
|
||||||
// Year search - match movies from that year
|
|
||||||
if (isYearSearch) {
|
|
||||||
if (movie.year === searchYear) {
|
|
||||||
allScored.push({
|
|
||||||
item: movieToMediaItem(movie),
|
|
||||||
score: 100 + (movie.info?.rating ?? 0) / 10, // High base score, ranked by rating
|
|
||||||
matchType: 'movies'
|
|
||||||
});
|
|
||||||
processedIds.add(movie.id);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct title match -> Movies category
|
// Direct title match -> Movies category
|
||||||
const titleScore = getBestScore(query, movie.title, movie.info?.original_title);
|
const titleScore = getBestScore(query, movie.title, movie.info?.original_title);
|
||||||
if (titleScore > 0) {
|
const yearScore = isYearQuery && movie.year === searchYear ? yearBonus : 0;
|
||||||
|
if (titleScore > 0 || yearScore > 0) {
|
||||||
allScored.push({
|
allScored.push({
|
||||||
item: movieToMediaItem(movie),
|
item: movieToMediaItem(movie),
|
||||||
score: titleScore + (movie.info?.rating ?? 0) / 10,
|
score: titleScore + yearScore + (movie.info?.rating ?? 0) / 10,
|
||||||
matchType: 'movies'
|
matchType: 'movies'
|
||||||
});
|
});
|
||||||
processedIds.add(movie.id);
|
processedIds.add(movie.id);
|
||||||
@@ -981,27 +1019,15 @@ function performSearch(query: string) {
|
|||||||
|
|
||||||
// Search series
|
// Search series
|
||||||
for (const series of mediaIndex.value.series) {
|
for (const series of mediaIndex.value.series) {
|
||||||
// Year search - match series that started that year
|
|
||||||
if (isYearSearch) {
|
|
||||||
// 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;
|
|
||||||
if (seriesYear === searchYear) {
|
|
||||||
allScored.push({
|
|
||||||
item: seriesToMediaItem(series),
|
|
||||||
score: 100 + (series.info?.rating ?? 0) / 10,
|
|
||||||
matchType: 'series'
|
|
||||||
});
|
|
||||||
processedIds.add(series.id);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct title match -> Series category
|
// Direct title match -> Series category
|
||||||
const titleScore = getBestScore(query, series.title, series.info?.original_title);
|
const titleScore = getBestScore(query, series.title, series.info?.original_title);
|
||||||
if (titleScore > 0) {
|
// 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({
|
allScored.push({
|
||||||
item: seriesToMediaItem(series),
|
item: seriesToMediaItem(series),
|
||||||
score: titleScore + (series.info?.rating ?? 0) / 10,
|
score: titleScore + yearScore + (series.info?.rating ?? 0) / 10,
|
||||||
matchType: 'series'
|
matchType: 'series'
|
||||||
});
|
});
|
||||||
processedIds.add(series.id);
|
processedIds.add(series.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user