Added fuzzy finder to search, made sync check as middleware, .id file is never used
Build / build (push) Successful in 45s

This commit is contained in:
2026-07-02 09:30:24 +02:00
parent a8df738108
commit eda60e0c07
9 changed files with 522 additions and 282 deletions
-117
View File
@@ -1,117 +0,0 @@
package web
import (
"log"
"music-server/internal/backend"
"net/http"
"regexp"
"strings"
)
var soundtracks_added []string
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
soundtracks := backend.GetAllSoundtracks()
for _, soundtrack := range soundtracks {
if is_match_exact(searchText, soundtrack) {
add_soundtrack(soundtrack)
}
}
for _, soundtrack := range soundtracks {
if is_match_contains(clean_term(searchText), clean_term(soundtrack)) {
add_soundtrack(soundtrack)
}
}
for _, soundtrack := range soundtracks {
if is_match_regex(clean_term(searchText), clean_term(soundtrack)) {
add_soundtrack(soundtrack)
}
}
}
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 add_soundtrack(soundtrack string) {
if !check_if_soundtrack_exists(soundtrack) {
soundtracks_added = append(soundtracks_added, soundtrack)
}
}
func check_if_soundtrack_exists(soundtrackName string) bool {
soundtrack_exists := false
for _, child := range soundtracks_added {
if child == soundtrackName {
soundtrack_exists = true
}
}
return soundtrack_exists
}
func compile_regex(search_term string) *regexp.Regexp {
regText := ".*"
for _, letter := range search_term {
regText += string(letter) + ".*"
}
r, _ := regexp.Compile(regText)
return r
}
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)
}
+403
View File
@@ -0,0 +1,403 @@
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)
}
}
+33 -6
View File
@@ -1,19 +1,45 @@
package web
templ HelloForm() {
templ SearchForm() {
@Base() {
<button id="dark-mode-toggle">🌙</button>
<div id="search-container">
<input id="search_term" name="search_term" type="text" hx-post="/find" hx-trigger="keyup changed delay:0.25s" hx-target="#soundtracks-container"/>
<input id="search_term" name="search_term" type="text" hx-post="/findfuzzy" hx-trigger="keyup changed delay:0.25s" hx-target="#soundtracks-container"/>
<div class="radio-group" style="display: inline-block; margin-left: 10px;">
<label><input type="radio" name="search_type" value="fuzzy" checked> Fuzzy</label>
<label><input type="radio" name="search_type" value="normal"> Normal</label>
</div>
<button type="button" id="clear" name="clear">Clear</button>
</div>
<div id="soundtracks-container"></div>
<script>
document.addEventListener('readystatechange', () => {
// Get current search type from radio buttons
function getSearchType() {
return document.querySelector('input[name="search_type"]:checked').value;
}
// Get endpoint based on search type
function getSearchEndpoint() {
return getSearchType() === 'fuzzy' ? '/findfuzzy' : '/find';
}
// Update search input endpoint
function updateSearchEndpoint() {
const endpoint = getSearchEndpoint();
document.getElementById('search_term').setAttribute('hx-post', endpoint);
}
document.addEventListener('readystatechange', () => {
if (document.readyState == 'complete') {
htmx.ajax('POST', '/find', '#soundtracks-container');
// Initialize with fuzzy search (default)
htmlx.ajax('POST', '/findfuzzy', '#soundtracks-container');
document.getElementById("search_term").focus();
// Add event listeners for radio buttons
document.querySelectorAll('input[name="search_type"]').forEach(radio => {
radio.addEventListener('change', updateSearchEndpoint);
});
// Initialize dark mode from localStorage (default to dark)
const savedTheme = localStorage.getItem('theme') || 'dark';
if (savedTheme === 'dark') {
@@ -38,7 +64,8 @@ templ HelloForm() {
document.getElementById("clear").addEventListener("click", function (event) {
document.getElementById("search_term").value = "";
htmx.ajax('POST', '/find', '#soundtracks-container');
// Use current search type endpoint
htmlx.ajax('POST', getSearchEndpoint(), '#soundtracks-container');
document.getElementById("search_term").focus();
});
</script>
@@ -51,4 +78,4 @@ templ FoundSoundtracks(soundtracks []string) {
<p class="soundtrack-text">{ soundtrack }</p>
</div>
}
}
}