diff --git a/cmd/web/hello.go b/cmd/web/hello.go deleted file mode 100644 index 680c927..0000000 --- a/cmd/web/hello.go +++ /dev/null @@ -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) -} diff --git a/cmd/web/search.go b/cmd/web/search.go new file mode 100644 index 0000000..d91a068 --- /dev/null +++ b/cmd/web/search.go @@ -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) + } +} diff --git a/cmd/web/hello.templ b/cmd/web/search.templ similarity index 54% rename from cmd/web/hello.templ rename to cmd/web/search.templ index fbd8191..11aab9e 100644 --- a/cmd/web/hello.templ +++ b/cmd/web/search.templ @@ -1,19 +1,45 @@ package web -templ HelloForm() { +templ SearchForm() { @Base() {
- + +
+ + +
@@ -51,4 +78,4 @@ templ FoundSoundtracks(soundtracks []string) {

{ soundtrack }

} -} +} \ No newline at end of file diff --git a/internal/backend/sync.go b/internal/backend/sync.go index fb02baf..6dff1b6 100644 --- a/internal/backend/sync.go +++ b/internal/backend/sync.go @@ -175,17 +175,17 @@ func SyncResult() SyncResponse { } } -func SyncSoundtracksNewFull() { - syncSoundtracksNew(true) +func SyncSoundtracksFull() { + syncSoundtracks(true) Reset() } -func SyncSoundtracksNewOnlyChanges() { - syncSoundtracksNew(false) +func SyncSoundtracksOnlyChanges() { + syncSoundtracks(false) Reset() } -func syncSoundtracksNew(full bool) { +func syncSoundtracks(full bool) { musicPath := os.Getenv("MUSIC_PATH") fmt.Printf("dir: %s\n", musicPath) logging.GetLogger().Debug("Folder to sync", zap.String("MUSIC_PATH", musicPath)) @@ -233,11 +233,11 @@ func syncSoundtracksNew(full bool) { for _, dir := range directories { pool.Submit(func() { defer syncWg.Done() - syncSoundtrackNew(dir, foldersToSkip, musicPath, full) + syncSoundtrack(dir, foldersToSkip, musicPath, full) }) } syncWg.Wait() - checkBrokenSongsNew() + checkBrokenSongs() soundtracksAfterSync, err = repo.FindAllSoundtracks(BackendCtx()) handleError("FindAllSoundtracks After", err, "") @@ -250,7 +250,7 @@ func syncSoundtracksNew(full bool) { Syncing = false } -func checkBrokenSongsNew() { +func checkBrokenSongs() { allSongs, err := repo.FetchAllSongs(BackendCtx()) handleError("FetchAllSongs", err, "") var brokenWg sync.WaitGroup @@ -261,7 +261,7 @@ func checkBrokenSongsNew() { for _, song := range allSongs { poolBroken.Submit(func() { defer brokenWg.Done() - checkBrokenSongNew(song) + checkBrokenSong(song) }) } brokenWg.Wait() @@ -271,7 +271,7 @@ func checkBrokenSongsNew() { } } -func checkBrokenSongNew(song repository.Song) { +func checkBrokenSong(song repository.Song) { //Check if file exists and open openFile, err := os.Open(song.Path) if err != nil { @@ -286,7 +286,7 @@ func checkBrokenSongNew(song repository.Song) { } } -func syncSoundtrackNew(file os.DirEntry, foldersToSkip []string, baseDir string, full bool) { +func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, full bool) { if file.IsDir() && !contains(foldersToSkip, file.Name()) { logging.GetLogger().Debug("Syncing soundtrack", zap.String("soundtrack", file.Name())) soundtrackDir := baseDir + file.Name() + "/" @@ -328,46 +328,14 @@ func syncSoundtrackNew(file os.DirEntry, foldersToSkip []string, baseDir string, } switch status { case NewSoundtrack: - if id != -1 { - for _, entry := range entries { - fileInfo, err := entry.Info() - if err != nil { - logging.GetLogger().Error("Failed to get file info", zap.String("error", err.Error())) - continue - } - id = getIdFromFileNew(fileInfo) - if id != -1 { - break - } - } - err = repo.InsertSoundtrackWithExistingId(BackendCtx(), repository.InsertSoundtrackWithExistingIdParams{ID: id, SoundtrackName: file.Name(), Path: soundtrackDir, Hash: dirHash}) - handleError("InsertSoundtrackWithExistingId", err, "") - if err != nil { - logging.GetLogger().Debug("Soundtrack already exists, removing old ID file", - zap.Int32("id", id), - zap.String("soundtrack_dir", soundtrackDir)) - fileName := soundtrackDir + "/." + strconv.Itoa(int(id)) + ".id" - logging.GetLogger().Debug("Removing ID file", zap.String("filename", fileName)) - - err := os.Remove(fileName) - if err != nil { - logging.GetLogger().Error("Failed to remove ID file", zap.String("filename", fileName), zap.String("error", err.Error())) - } - - newDirHash := getHashForDir(soundtrackDir) - - id = insertSoundtrackNew(file.Name(), soundtrackDir, newDirHash) - } - } else { - id = insertSoundtrackNew(file.Name(), soundtrackDir, dirHash) - } + id = insertSoundtrack(file.Name(), soundtrackDir, dirHash) logging.GetLogger().Debug("New soundtrack detected", zap.Int32("id", id), zap.String("soundtrack", file.Name()), zap.String("hash", dirHash), zap.String("status", status.String())) soundtracksAdded = append(soundtracksAdded, file.Name()) - newCheckSongs(entries, soundtrackDir, id) + checkSongs(entries, soundtrackDir, id) case SoundtrackChanged: logging.GetLogger().Debug("Soundtrack changed", zap.Int32("id", id), @@ -377,7 +345,7 @@ func syncSoundtrackNew(file os.DirEntry, foldersToSkip []string, baseDir string, err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id}) handleError("UpdateSoundtrackHash", err, "") soundtracksChangedContent = append(soundtracksChangedContent, file.Name()) - newCheckSongs(entries, soundtrackDir, id) + checkSongs(entries, soundtrackDir, id) case TitleChanged: logging.GetLogger().Debug("Soundtrack title changed", zap.Int32("id", id), @@ -387,7 +355,7 @@ func syncSoundtrackNew(file os.DirEntry, foldersToSkip []string, baseDir string, zap.String("status", status.String())) err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: soundtrackDir, ID: id}) handleError("UpdateSoundtrackName", err, "") - newCheckSongs(entries, soundtrackDir, id) + checkSongs(entries, soundtrackDir, id) if soundtracksChangedTitle == nil { soundtracksChangedTitle = make(map[string]string) } @@ -405,7 +373,7 @@ func syncSoundtrackNew(file os.DirEntry, foldersToSkip []string, baseDir string, } } if !found { - newCheckSongs(entries, soundtrackDir, id) + checkSongs(entries, soundtrackDir, id) soundtracksReAdded = append(soundtracksReAdded, file.Name()) logging.GetLogger().Debug("Soundtrack added again", zap.Int32("id", id), @@ -430,7 +398,7 @@ func syncSoundtrackNew(file os.DirEntry, foldersToSkip []string, baseDir string, zap.Int("percent", int((foldersSynced/numberOfFoldersToSync)*100))) } -func insertSoundtrackNew(name string, path string, hash string) int32 { +func insertSoundtrack(name string, path string, hash string) int32 { var duplicateError = errors.New("ERROR: duplicate key value violates unique") id, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{SoundtrackName: name, Path: path, Hash: hash}) handleError("InsertSoundtrack", err, "") @@ -440,14 +408,14 @@ func insertSoundtrackNew(name string, path string, hash string) int32 { logging.GetLogger().Debug("Resetting soundtrack ID sequence") _, err = repo.ResetSoundtrackIdSeq(BackendCtx()) handleError("ResetSoundtrackIdSeq", err, "") - id = insertSoundtrackNew(name, path, hash) + id = insertSoundtrack(name, path, hash) } } return id } -func newCheckSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 { +func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 { //hasher := md5.New() var numberOfSongs int32 numberOfFiles := len(entries) @@ -457,7 +425,7 @@ func newCheckSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 for _, entry := range entries { poolSong.Submit(func() { defer songWg.Done() - if newCheckSong(entry, soundtrackDir, id) { + if checkSong(entry, soundtrackDir, id) { numberOfSongs++ } }) @@ -466,7 +434,7 @@ func newCheckSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 return numberOfSongs } -func newCheckSong(entry os.DirEntry, soundtrackDir string, id int32) bool { +func checkSong(entry os.DirEntry, soundtrackDir string, id int32) bool { fileInfo, err := entry.Info() if err != nil { logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error())) @@ -570,7 +538,7 @@ func getHashForFile(path string) string { return hex.EncodeToString(hasher.Sum(nil)) } -func getIdFromFileNew(file os.FileInfo) int32 { +func getIdFromFile(file os.FileInfo) int32 { name := file.Name() if !file.IsDir() && strings.HasSuffix(name, ".id") { name = strings.Replace(name, ".id", "", 1) diff --git a/internal/backend/sync_test.go b/internal/backend/sync_test.go index 4bed894..a194444 100644 --- a/internal/backend/sync_test.go +++ b/internal/backend/sync_test.go @@ -9,10 +9,10 @@ import ( func TestContains(t *testing.T) { tests := []struct { - name string - slice []string - search string - expected bool + name string + slice []string + search string + expected bool }{ { name: "element exists", @@ -155,9 +155,9 @@ func TestGetIdFromFileNew(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := getIdFromFileNew(tt.fileInfo) + result := getIdFromFile(tt.fileInfo) if result != tt.expected { - t.Errorf("getIdFromFileNew() = %v, want %v", result, tt.expected) + t.Errorf("getIdFromFile() = %v, want %v", result, tt.expected) } }) } @@ -172,10 +172,10 @@ type mockFileInfoForSong struct { func (m *mockFileInfoForSong) Name() string { return m.name } func (m *mockFileInfoForSong) Size() int64 { return m.size } -func (m *mockFileInfoForSong) Mode() os.FileMode { return 0 } -func (m *mockFileInfoForSong) ModTime() time.Time { return time.Time{} } -func (m *mockFileInfoForSong) IsDir() bool { return m.isDir } -func (m *mockFileInfoForSong) Sys() interface{} { return nil } +func (m *mockFileInfoForSong) Mode() os.FileMode { return 0 } +func (m *mockFileInfoForSong) ModTime() time.Time { return time.Time{} } +func (m *mockFileInfoForSong) IsDir() bool { return m.isDir } +func (m *mockFileInfoForSong) Sys() interface{} { return nil } type mockFileInfoForCover struct { name string @@ -185,10 +185,10 @@ type mockFileInfoForCover struct { func (m *mockFileInfoForCover) Name() string { return m.name } func (m *mockFileInfoForCover) Size() int64 { return m.size } -func (m *mockFileInfoForCover) Mode() os.FileMode { return 0 } -func (m *mockFileInfoForCover) ModTime() time.Time { return time.Time{} } -func (m *mockFileInfoForCover) IsDir() bool { return m.isDir } -func (m *mockFileInfoForCover) Sys() interface{} { return nil } +func (m *mockFileInfoForCover) Mode() os.FileMode { return 0 } +func (m *mockFileInfoForCover) ModTime() time.Time { return time.Time{} } +func (m *mockFileInfoForCover) IsDir() bool { return m.isDir } +func (m *mockFileInfoForCover) Sys() interface{} { return nil } type mockFileInfoForId struct { name string @@ -198,7 +198,7 @@ type mockFileInfoForId struct { func (m *mockFileInfoForId) Name() string { return m.name } func (m *mockFileInfoForId) Size() int64 { return m.size } -func (m *mockFileInfoForId) Mode() os.FileMode { return 0 } -func (m *mockFileInfoForId) ModTime() time.Time { return time.Time{} } -func (m *mockFileInfoForId) IsDir() bool { return m.isDir } -func (m *mockFileInfoForId) Sys() interface{} { return nil } +func (m *mockFileInfoForId) Mode() os.FileMode { return 0 } +func (m *mockFileInfoForId) ModTime() time.Time { return time.Time{} } +func (m *mockFileInfoForId) IsDir() bool { return m.isDir } +func (m *mockFileInfoForId) Sys() interface{} { return nil } diff --git a/internal/server/middleware/sync_check.go b/internal/server/middleware/sync_check.go new file mode 100644 index 0000000..8931458 --- /dev/null +++ b/internal/server/middleware/sync_check.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "music-server/internal/backend" + "music-server/internal/logging" + "net/http" + + "github.com/labstack/echo/v5" +) + +// SyncCheckMiddleware blocks requests when syncing is in progress. +// It returns HTTP 423 Locked with a standard message. +// This middleware should be applied to handlers that cannot execute during sync. +func SyncCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + if backend.Syncing { + logging.GetLogger().Info("Syncing is in progress - request blocked") + return c.JSON(http.StatusLocked, "Syncing is in progress") + } + return next(c) + } +} diff --git a/internal/server/musicHandler.go b/internal/server/musicHandler.go index 917e367..e5b2ef3 100644 --- a/internal/server/musicHandler.go +++ b/internal/server/musicHandler.go @@ -31,10 +31,6 @@ func NewMusicHandler() *MusicHandler { // @Failure 423 {string} string "Syncing is in progress" // @Router /music [get] func (m *MusicHandler) GetSong(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } song := ctx.QueryParam("song") if song == "" { return ctx.String(http.StatusBadRequest, "song can't be empty") @@ -58,10 +54,6 @@ func (m *MusicHandler) GetSong(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /music/soundTest [get] func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } songPath := backend.GetSoundCheckSong() file, err := os.Open(songPath) if err != nil { @@ -80,10 +72,6 @@ func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /music/reset [get] func (m *MusicHandler) ResetMusic(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } backend.Reset() return ctx.NoContent(http.StatusOK) } @@ -98,10 +86,6 @@ func (m *MusicHandler) ResetMusic(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /music/rand [get] func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } songPath := backend.GetRandomSong() file, err := os.Open(songPath) if err != nil { @@ -121,10 +105,6 @@ func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /music/rand/low [get] func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } songPath := backend.GetRandomSongLowChance() file, err := os.Open(songPath) if err != nil { @@ -144,10 +124,6 @@ func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /music/rand/classic [get] func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } songPath := backend.GetRandomSongClassic() file, err := os.Open(songPath) if err != nil { @@ -193,10 +169,6 @@ func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /music/next [get] func (m *MusicHandler) GetNextSong(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } songPath := backend.GetNextSong() file, err := os.Open(songPath) if err != nil { @@ -216,10 +188,6 @@ func (m *MusicHandler) GetNextSong(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /music/previous [get] func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } songPath := backend.GetPreviousSong() file, err := os.Open(songPath) if err != nil { @@ -239,10 +207,6 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /music/all/order [get] func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } soundtrackList := backend.GetAllSoundtracks() return ctx.JSON(http.StatusOK, soundtrackList) } @@ -257,10 +221,6 @@ func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /music/all/random [get] func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } soundtrackList := backend.GetAllSoundtracksRandom() return ctx.JSON(http.StatusOK, soundtrackList) } @@ -277,10 +237,6 @@ func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /music/played [put] func (m *MusicHandler) PutPlayed(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } song, err := strconv.Atoi(ctx.QueryParam("song")) if err != nil { return ctx.JSON(http.StatusBadRequest, err.Error()) @@ -299,10 +255,6 @@ func (m *MusicHandler) PutPlayed(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /music/addQue [get] func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } backend.AddLatestToQue() return ctx.NoContent(http.StatusOK) } @@ -316,10 +268,6 @@ func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /music/addPlayed [get] func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Info("Syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } backend.AddLatestPlayed() return ctx.NoContent(http.StatusOK) } diff --git a/internal/server/routes.go b/internal/server/routes.go index 913485b..ffa06f1 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -50,8 +50,9 @@ func (s *Server) RegisterRoutes() http.Handler { fileServer := http.FileServer(http.FS(web.Assets)) e.GET("/assets/*", echo.WrapHandler(fileServer)) - e.GET("/search", echo.WrapHandler(templ.Handler(web.HelloForm()))) + e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm()))) e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler))) + e.POST("/findfuzzy", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackFuzzyWebHandler))) e.Static("/", "/frontend") @@ -82,32 +83,32 @@ func (s *Server) RegisterRoutes() http.Handler { sync := NewSyncHandler() syncGroup := e.Group("/sync") - syncGroup.GET("", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges)) + syncGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges))) syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress)) - syncGroup.GET("/new", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges)) - syncGroup.GET("/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull)) - syncGroup.GET("/new/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull)) - syncGroup.GET("/quick", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges)) - syncGroup.GET("/reset", deprecatedMiddleware(sync.ResetDB)) + syncGroup.GET("/new", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges))) + syncGroup.GET("/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull))) + syncGroup.GET("/new/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull))) + syncGroup.GET("/quick", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges))) + syncGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.ResetDB))) music := NewMusicHandler() musicGroup := e.Group("/music") - musicGroup.GET("", deprecatedMiddleware(music.GetSong)) - musicGroup.GET("/soundTest", deprecatedMiddleware(music.GetSoundCheckSong)) - musicGroup.GET("/reset", deprecatedMiddleware(music.ResetMusic)) - musicGroup.GET("/rand", deprecatedMiddleware(music.GetRandomSong)) - musicGroup.GET("/rand/low", deprecatedMiddleware(music.GetRandomSongLowChance)) - musicGroup.GET("/rand/classic", deprecatedMiddleware(music.GetRandomSongClassic)) + musicGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSong))) + musicGroup.GET("/soundTest", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSoundCheckSong))) + musicGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.ResetMusic))) + musicGroup.GET("/rand", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSong))) + musicGroup.GET("/rand/low", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongLowChance))) + musicGroup.GET("/rand/classic", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongClassic))) musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo)) musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs)) - musicGroup.GET("/next", deprecatedMiddleware(music.GetNextSong)) - musicGroup.GET("/previous", deprecatedMiddleware(music.GetPreviousSong)) - musicGroup.GET("/all", deprecatedMiddleware(music.GetAllSoundtracksRandom)) - musicGroup.GET("/all/order", deprecatedMiddleware(music.GetAllSoundtracks)) - musicGroup.GET("/all/random", deprecatedMiddleware(music.GetAllSoundtracksRandom)) - musicGroup.PUT("/played", deprecatedMiddleware(music.PutPlayed)) - musicGroup.GET("/addQue", deprecatedMiddleware(music.AddLatestToQue)) - musicGroup.GET("/addPlayed", deprecatedMiddleware(music.AddLatestPlayed)) + musicGroup.GET("/next", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetNextSong))) + musicGroup.GET("/previous", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetPreviousSong))) + musicGroup.GET("/all", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom))) + musicGroup.GET("/all/order", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracks))) + musicGroup.GET("/all/random", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom))) + musicGroup.PUT("/played", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.PutPlayed))) + musicGroup.GET("/addQue", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestToQue))) + musicGroup.GET("/addPlayed", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestPlayed))) // ============================================ // API v1 Routes with Token Authentication diff --git a/internal/server/syncHandler.go b/internal/server/syncHandler.go index 497f0e9..4227ddd 100644 --- a/internal/server/syncHandler.go +++ b/internal/server/syncHandler.go @@ -44,13 +44,9 @@ func (s *SyncHandler) SyncProgress(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /sync [get] func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Warn("Syncing is already in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } logging.GetLogger().Info("Starting sync with only changes") backend.Syncing = true - go backend.SyncSoundtracksNewOnlyChanges() + go backend.SyncSoundtracksOnlyChanges() return ctx.JSON(http.StatusOK, "Start syncing soundtracks") } @@ -64,13 +60,9 @@ func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /sync/full [get] func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Warn("Syncing is already in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } logging.GetLogger().Info("Starting full sync") backend.Syncing = true - go backend.SyncSoundtracksNewFull() + go backend.SyncSoundtracksFull() return ctx.JSON(http.StatusOK, "Start syncing soundtracks full") } @@ -84,10 +76,6 @@ func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error { // @Failure 423 {string} string "Syncing is in progress" // @Router /sync/reset [get] func (s *SyncHandler) ResetDB(ctx *echo.Context) error { - if backend.Syncing { - logging.GetLogger().Warn("Cannot reset - syncing is in progress") - return ctx.JSON(http.StatusLocked, "Syncing is in progress") - } logging.GetLogger().Info("Resetting soundtracks database") backend.ResetDB() return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")