404 lines
9.8 KiB
Go
404 lines
9.8 KiB
Go
package web
|
|
|
|
import (
|
|
"log"
|
|
"music-server/internal/backend"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
)
|
|
|
|
var soundtracks_added []string
|
|
|
|
// Precomputed data for optimization
|
|
type SoundtrackData struct {
|
|
Original string
|
|
Cleaned string
|
|
Abbreviation string
|
|
}
|
|
|
|
var (
|
|
precomputedSoundtracks []SoundtrackData
|
|
precomputedOnce sync.Once
|
|
regexCache = make(map[string]*regexp.Regexp)
|
|
regexCacheMutex sync.Mutex
|
|
)
|
|
|
|
func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) {
|
|
err := r.ParseForm()
|
|
if err != nil {
|
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
|
}
|
|
|
|
search_term := r.FormValue("search_term")
|
|
|
|
search(search_term)
|
|
|
|
component := FoundSoundtracks(soundtracks_added)
|
|
err = component.Render(r.Context(), w)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
log.Fatalf("Error rendering in FindSoundtrackWebHandler: %e", err)
|
|
}
|
|
}
|
|
|
|
func search(searchText string) {
|
|
soundtracks_added = nil
|
|
|
|
// Precompute cleaned search term once
|
|
cleanedSearchTerm := clean_term(searchText)
|
|
|
|
// Use precomputed data for efficiency
|
|
soundtrackData := getPrecomputedSoundtracks()
|
|
seen := make(map[string]bool) // O(1) duplicate checking
|
|
|
|
for _, data := range soundtrackData {
|
|
// Check exact match (case-insensitive contains)
|
|
if is_match_exact(searchText, data.Original) && !seen[data.Original] {
|
|
soundtracks_added = append(soundtracks_added, data.Original)
|
|
seen[data.Original] = true
|
|
}
|
|
}
|
|
|
|
for _, data := range soundtrackData {
|
|
// Check contains match with cleaned terms
|
|
if !seen[data.Original] && is_match_contains(cleanedSearchTerm, data.Cleaned) {
|
|
soundtracks_added = append(soundtracks_added, data.Original)
|
|
seen[data.Original] = true
|
|
}
|
|
}
|
|
|
|
for _, data := range soundtrackData {
|
|
// Check regex match with cleaned terms
|
|
if !seen[data.Original] && is_match_regex_cached(cleanedSearchTerm, data.Cleaned) {
|
|
soundtracks_added = append(soundtracks_added, data.Original)
|
|
seen[data.Original] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func is_match_exact(search_term string, soundtrack_name string) bool {
|
|
search_term = strings.ToLower(search_term)
|
|
soundtrack_name = strings.ToLower(soundtrack_name)
|
|
|
|
if search_term == "" {
|
|
return true
|
|
} else if strings.Contains(soundtrack_name, search_term) {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
func is_match_contains(search_term string, soundtrack_name string) bool {
|
|
if search_term == "" {
|
|
return true
|
|
} else if strings.Contains(soundtrack_name, search_term) {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
func is_match_regex(search_term string, soundtrack_name string) bool {
|
|
if search_term == "" {
|
|
return true
|
|
} else if compile_regex(search_term).MatchString(soundtrack_name) {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
func compile_regex(search_term string) *regexp.Regexp {
|
|
regText := ".*"
|
|
for _, letter := range search_term {
|
|
regText += string(letter) + ".*"
|
|
}
|
|
r, _ := regexp.Compile(regText)
|
|
|
|
return r
|
|
}
|
|
|
|
// is_match_regex_cached uses cached compiled regex patterns for better performance
|
|
func is_match_regex_cached(search_term string, soundtrack_name string) bool {
|
|
if search_term == "" {
|
|
return true
|
|
}
|
|
|
|
// Check cache first
|
|
regexCacheMutex.Lock()
|
|
re, exists := regexCache[search_term]
|
|
if !exists {
|
|
re = compile_regex(search_term)
|
|
regexCache[search_term] = re
|
|
}
|
|
regexCacheMutex.Unlock()
|
|
|
|
return re.MatchString(soundtrack_name)
|
|
}
|
|
|
|
func clean_term(term string) string {
|
|
term = strings.ReplaceAll(term, " ", "")
|
|
term = strings.ReplaceAll(term, "é", "e")
|
|
term = strings.ReplaceAll(term, "+", "plus")
|
|
term = strings.ReplaceAll(term, "&", "and")
|
|
term = strings.ReplaceAll(term, "'n", "and")
|
|
return strings.ToLower(term)
|
|
}
|
|
|
|
// toLower converts a string to lowercase.
|
|
func toLower(s string) string {
|
|
return strings.Map(unicode.ToLower, s)
|
|
}
|
|
|
|
// levenshteinWithThreshold calculates Levenshtein distance with early termination and space optimization.
|
|
// Uses O(min(n,m)) space instead of O(n*m) and stops early if threshold is exceeded.
|
|
func levenshteinWithThreshold(s, t string, threshold int) int {
|
|
if threshold < 0 {
|
|
threshold = 2 // default threshold
|
|
}
|
|
|
|
m, n := len(s), len(t)
|
|
|
|
// Quick checks for early termination
|
|
if m == 0 {
|
|
return n
|
|
}
|
|
if n == 0 {
|
|
return m
|
|
}
|
|
if abs(m-n) > threshold {
|
|
return threshold + 1 // Can't be within threshold
|
|
}
|
|
|
|
// Use the shorter string for the row to minimize space
|
|
if m < n {
|
|
s, t = t, s
|
|
m, n = n, m
|
|
}
|
|
|
|
// Space optimization: only store two rows
|
|
prevRow := make([]int, n+1)
|
|
currRow := make([]int, n+1)
|
|
|
|
// Initialize first row
|
|
for j := 0; j <= n; j++ {
|
|
prevRow[j] = j
|
|
}
|
|
|
|
for i := 1; i <= m; i++ {
|
|
currRow[0] = i
|
|
minInRow := currRow[0]
|
|
|
|
for j := 1; j <= n; j++ {
|
|
if s[i-1] == t[j-1] {
|
|
currRow[j] = prevRow[j-1]
|
|
} else {
|
|
currRow[j] = min3(prevRow[j], currRow[j-1], prevRow[j-1]) + 1
|
|
}
|
|
if currRow[j] < minInRow {
|
|
minInRow = currRow[j]
|
|
}
|
|
}
|
|
|
|
// Early termination: if minimum in current row exceeds threshold
|
|
if minInRow > threshold {
|
|
return threshold + 1
|
|
}
|
|
|
|
// Swap rows for next iteration
|
|
prevRow, currRow = currRow, prevRow
|
|
}
|
|
|
|
return prevRow[n]
|
|
}
|
|
|
|
// Helper functions for optimized Levenshtein
|
|
func abs(x int) int {
|
|
if x < 0 {
|
|
return -x
|
|
}
|
|
return x
|
|
}
|
|
|
|
func min3(a, b, c int) int {
|
|
if a < b {
|
|
if a < c {
|
|
return a
|
|
}
|
|
return c
|
|
}
|
|
if b < c {
|
|
return b
|
|
}
|
|
return c
|
|
}
|
|
|
|
// precomputeSoundtrackData initializes cleaned terms and abbreviations for all soundtracks
|
|
func precomputeSoundtrackData() {
|
|
precomputedOnce.Do(func() {
|
|
soundtracks := backend.GetAllSoundtracks()
|
|
precomputedSoundtracks = make([]SoundtrackData, len(soundtracks))
|
|
|
|
for i, soundtrack := range soundtracks {
|
|
cleaned := clean_term(soundtrack)
|
|
precomputedSoundtracks[i] = SoundtrackData{
|
|
Original: soundtrack,
|
|
Cleaned: cleaned,
|
|
Abbreviation: extractAbbreviation(soundtrack),
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// getPrecomputedSoundtracks ensures precomputed data is available and returns it
|
|
func getPrecomputedSoundtracks() []SoundtrackData {
|
|
precomputeSoundtrackData()
|
|
return precomputedSoundtracks
|
|
}
|
|
|
|
// levenshtein maintains backward compatibility by calling the optimized version
|
|
func levenshtein(s, t string) int {
|
|
return levenshteinWithThreshold(s, t, -1) // -1 uses default threshold
|
|
}
|
|
|
|
// extractAbbreviation extracts the first letters of each word in a string.
|
|
func extractAbbreviation(s string) string {
|
|
words := strings.Fields(s)
|
|
abbr := ""
|
|
for _, word := range words {
|
|
if len(word) > 0 {
|
|
abbr += string(word[0])
|
|
}
|
|
}
|
|
return toLower(abbr)
|
|
}
|
|
|
|
// fuzzyFindSubstring checks if the query fuzzy-matches any substring of the item.
|
|
func fuzzyFindSubstring(query, item string, threshold int) (bool, int) {
|
|
query = toLower(query)
|
|
item = toLower(item)
|
|
queryLen := len(query)
|
|
itemLen := len(item)
|
|
if queryLen > itemLen {
|
|
return false, 0
|
|
}
|
|
|
|
// Check for exact substring match first (weight: 100)
|
|
if strings.Contains(item, query) {
|
|
return true, 100
|
|
}
|
|
|
|
// Check for fuzzy substring match using optimized Levenshtein
|
|
for i := 0; i <= itemLen-queryLen; i++ {
|
|
substring := item[i : i+queryLen]
|
|
distance := levenshteinWithThreshold(query, substring, threshold)
|
|
if distance <= threshold {
|
|
// Weight: higher for matches at the start of the string
|
|
weight := 50 - i // Higher weight for earlier matches
|
|
return true, weight
|
|
}
|
|
}
|
|
return false, 0
|
|
}
|
|
|
|
// fuzzyFind checks if the query matches the item (substring or abbreviation).
|
|
func fuzzyFind(query string, item string, threshold int) (bool, int) {
|
|
query = toLower(query)
|
|
item = toLower(item)
|
|
|
|
// Check for substring fuzzy match
|
|
if matched, weight := fuzzyFindSubstring(query, item, threshold); matched {
|
|
return true, weight
|
|
}
|
|
|
|
// Check for abbreviation match using optimized Levenshtein
|
|
abbr := extractAbbreviation(item)
|
|
distance := levenshteinWithThreshold(query, abbr, threshold)
|
|
if distance <= threshold {
|
|
// Weight: higher for exact abbreviation matches
|
|
weight := 100 - distance*10 // Higher weight for exact matches
|
|
return true, weight
|
|
}
|
|
|
|
return false, 0
|
|
}
|
|
|
|
// getAdaptiveThreshold returns a threshold based on query length
|
|
func getAdaptiveThreshold(query string) int {
|
|
threshold := 2 // Base threshold for minor typos
|
|
queryLen := len(query)
|
|
if queryLen > 6 {
|
|
// For longer queries, allow more tolerance
|
|
threshold = queryLen / 3
|
|
if threshold < 2 {
|
|
threshold = 2
|
|
} else if threshold > 4 {
|
|
threshold = 4 // Cap at 4 for very long queries
|
|
}
|
|
}
|
|
return threshold
|
|
}
|
|
|
|
// fuzzySearch performs fuzzy search on soundtracks using the cleaned terms for consistency
|
|
func fuzzySearch(searchText string) []string {
|
|
query := clean_term(searchText)
|
|
threshold := getAdaptiveThreshold(query)
|
|
|
|
// Use precomputed data for efficiency
|
|
soundtrackData := getPrecomputedSoundtracks()
|
|
|
|
type match struct {
|
|
item string
|
|
weight int
|
|
}
|
|
var matches []match
|
|
seen := make(map[string]bool) // O(1) duplicate checking
|
|
|
|
for _, data := range soundtrackData {
|
|
if matched, weight := fuzzyFind(query, data.Cleaned, threshold); matched {
|
|
if !seen[data.Original] {
|
|
matches = append(matches, match{data.Original, weight})
|
|
seen[data.Original] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort matches by weight (descending)
|
|
sort.Slice(matches, func(i, j int) bool {
|
|
return matches[i].weight > matches[j].weight
|
|
})
|
|
|
|
// Extract just the soundtrack names in order
|
|
result := make([]string, len(matches))
|
|
for i, m := range matches {
|
|
result[i] = m.item
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// FindSoundtrackFuzzyWebHandler handles fuzzy search requests
|
|
func FindSoundtrackFuzzyWebHandler(w http.ResponseWriter, r *http.Request) {
|
|
err := r.ParseForm()
|
|
if err != nil {
|
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
search_term := r.FormValue("search_term")
|
|
|
|
results := fuzzySearch(search_term)
|
|
|
|
component := FoundSoundtracks(results)
|
|
err = component.Render(r.Context(), w)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
log.Fatalf("Error rendering in FindSoundtrackFuzzyWebHandler: %e", err)
|
|
}
|
|
}
|