From b7cd955897163cd2bee79144048b5454562d38ac Mon Sep 17 00:00:00 2001 From: Boidushya Date: Wed, 11 Mar 2026 20:54:51 +0530 Subject: [PATCH 1/3] fix: override --- config/config.go | 1 + handlers.go | 164 ++++++++++++++++++++++++++++ logcolors/colors.go | 1 + main_test.go | 183 ++++++++++++++++++++++++++++++++ routes.go | 3 + services/providers/ttml/ttml.go | 44 ++++++++ 6 files changed, 396 insertions(+) diff --git a/config/config.go b/config/config.go index fa87493..c985836 100644 --- a/config/config.go +++ b/config/config.go @@ -112,6 +112,7 @@ var APIKeyProtectedPaths = []string{ "/getLyrics", "/ttml/getLyrics", "/revalidate", + "/override", } // TTMLAccount represents a single TTML API account diff --git a/handlers.go b/handlers.go index 41e2539..b5a161c 100644 --- a/handlers.go +++ b/handlers.go @@ -1037,6 +1037,170 @@ func testNotifications(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +// overrideHandler replaces cached lyrics with content fetched by a specific Apple Music track ID. +// Finds all cache entries matching the song+artist query and updates their TTML field. +// Requires a valid API key (same pattern as revalidateHandler). +func overrideHandler(w http.ResponseWriter, r *http.Request) { + // 1. Require valid API key + apiKeyAuthenticated, _ := r.Context().Value(apiKeyAuthenticatedKey).(bool) + if !apiKeyAuthenticated { + Respond(w, r).Error(http.StatusUnauthorized, map[string]interface{}{ + "error": "API key required for override", + "message": "Provide a valid API key via X-API-Key header", + }) + return + } + + // 2. Parse params + trackID := r.URL.Query().Get("id") + songName := r.URL.Query().Get("s") + r.URL.Query().Get("song") + r.URL.Query().Get("songName") + artistName := r.URL.Query().Get("a") + r.URL.Query().Get("artist") + r.URL.Query().Get("artistName") + albumName := r.URL.Query().Get("al") + r.URL.Query().Get("album") + r.URL.Query().Get("albumName") + durationStr := r.URL.Query().Get("d") + r.URL.Query().Get("duration") + dryRun := r.URL.Query().Get("dry_run") == "true" + + // 3. Validate required params + if songName == "" || artistName == "" { + Respond(w, r).Error(http.StatusBadRequest, map[string]interface{}{ + "error": "song (s) and artist (a) parameters are required", + }) + return + } + + if trackID == "" && !dryRun { + Respond(w, r).Error(http.StatusBadRequest, map[string]interface{}{ + "error": "id parameter is required (Apple Music track ID)", + }) + return + } + + // 4. Find matching cache keys + matchString := strings.ToLower(strings.TrimSpace(songName)) + " " + strings.ToLower(strings.TrimSpace(artistName)) + normalizedAlbum := strings.ToLower(strings.TrimSpace(albumName)) + + var durationSec int + hasDuration := false + if durationStr != "" { + if _, err := fmt.Sscanf(durationStr, "%d", &durationSec); err == nil { + hasDuration = true + } + } + + deltaMs := conf.Configuration.DurationMatchDeltaMs + deltaSec := deltaMs / 1000 + if deltaSec < 1 { + deltaSec = 1 + } + + var matchingKeys []string + persistentCache.Range(func(key string, entry cache.CacheEntry) bool { + if !strings.HasPrefix(key, "ttml_lyrics:") { + return true + } + + query := strings.TrimPrefix(key, "ttml_lyrics:") + + // Key must contain the song+artist match string + if !strings.Contains(query, matchString) { + return true + } + + // If album provided, key must contain normalized album name + if normalizedAlbum != "" && !strings.Contains(query, normalizedAlbum) { + return true + } + + // If duration provided, extract duration from key and check within ±delta + if hasDuration { + var keyDuration int + // Cache key format: "song artist [album] [123s]" + if idx := strings.LastIndex(query, " "); idx != -1 { + suffix := query[idx+1:] + if strings.HasSuffix(suffix, "s") { + fmt.Sscanf(strings.TrimSuffix(suffix, "s"), "%d", &keyDuration) + } + } + if keyDuration > 0 { + diff := keyDuration - durationSec + if diff < 0 { + diff = -diff + } + if diff > deltaSec { + return true + } + } + } + + matchingKeys = append(matchingKeys, key) + return true + }) + + // 5. Dry run: return matching keys without modifying anything + if dryRun { + log.Infof("%s Dry run: found %d matching keys for %s", logcolors.LogOverride, len(matchingKeys), matchString) + Respond(w, r).JSON(map[string]interface{}{ + "dry_run": true, + "count": len(matchingKeys), + "keys": matchingKeys, + }) + return + } + + // 6. Fetch lyrics by track ID + log.Infof("%s Fetching lyrics for track ID %s to override %d cache entries", logcolors.LogOverride, trackID, len(matchingKeys)) + ttmlString, err := ttml.FetchLyricsByTrackID(trackID) + if err != nil { + log.Errorf("%s Failed to fetch lyrics for track ID %s: %v", logcolors.LogOverride, trackID, err) + Respond(w, r).Error(http.StatusInternalServerError, map[string]interface{}{ + "error": fmt.Sprintf("failed to fetch lyrics: %v", err), + "track_id": trackID, + }) + return + } + + // 7. Update matching cache entries, or create a new one if none exist + var updatedKeys []string + created := false + + if len(matchingKeys) == 0 { + // No existing entries — create a new cache entry using the same key format as /getLyrics + cacheKey := buildNormalizedCacheKey(songName, artistName, albumName, durationStr) + + var durationMs int + if durationStr != "" { + fmt.Sscanf(durationStr, "%d", &durationMs) + durationMs = durationMs * 1000 + } + + setCachedLyrics(cacheKey, ttmlString, durationMs, 0, "", false) + updatedKeys = append(updatedKeys, cacheKey) + created = true + log.Infof("%s Created new cache entry %s with lyrics from track ID %s", logcolors.LogOverride, cacheKey, trackID) + } else { + for _, key := range matchingKeys { + cached, ok := getCachedLyrics(key) + if !ok { + continue + } + + // Replace only the TTML content, preserve existing metadata + setCachedLyrics(key, ttmlString, cached.TrackDurationMs, cached.Score, cached.Language, cached.IsRTL) + updatedKeys = append(updatedKeys, key) + } + log.Infof("%s Updated %d cache entries with lyrics from track ID %s", logcolors.LogOverride, len(updatedKeys), trackID) + } + + // 8. Clear any negative cache entries for this query + deleteNegativeCache(buildNormalizedCacheKey(songName, artistName, albumName, durationStr)) + + Respond(w, r).JSON(map[string]interface{}{ + "updated": len(updatedKeys), + "created": created, + "keys": updatedKeys, + "track_id": trackID, + }) +} + // helpHandler returns API documentation func helpHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/logcolors/colors.go b/logcolors/colors.go index ead0745..01c1419 100644 --- a/logcolors/colors.go +++ b/logcolors/colors.go @@ -30,6 +30,7 @@ const ( LogCacheLyrics = Green + "[Cache:Lyrics]" + Reset LogCacheNegative = Cyan + "[Cache:Negative]" + Reset LogRevalidate = Cyan + "[Revalidate]" + Reset + LogOverride = Cyan + "[Override]" + Reset ) // Rate limiting log prefixes diff --git a/main_test.go b/main_test.go index 13b2681..6d49029 100644 --- a/main_test.go +++ b/main_test.go @@ -1,10 +1,14 @@ package main import ( + "context" "encoding/json" "errors" "lyrics-api-go/cache" + "net/http" + "net/http/httptest" "path/filepath" + "strings" "testing" "time" ) @@ -768,3 +772,182 @@ func TestGetCachedLyricsWithDurationTolerance_InvalidDuration(t *testing.T) { t.Error("Expected not to find cached lyrics with invalid duration") } } + +// Tests for overrideHandler + +func TestOverrideHandler_RequiresAPIKey(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + req, _ := http.NewRequest("GET", "/override?id=123&s=song&a=artist", nil) + // No API key context set — should be denied + rr := httptest.NewRecorder() + overrideHandler(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected 401, got %d", rr.Code) + } +} + +func TestOverrideHandler_RequiresSongAndArtist(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + tests := []struct { + name string + query string + }{ + {"missing both", "/override?id=123"}, + {"missing artist", "/override?id=123&s=song"}, + {"missing song", "/override?id=123&a=artist"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, _ := http.NewRequest("GET", tt.query, nil) + req = req.WithContext(context.WithValue(req.Context(), apiKeyAuthenticatedKey, true)) + rr := httptest.NewRecorder() + overrideHandler(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", rr.Code) + } + }) + } +} + +func TestOverrideHandler_RequiresTrackIDUnlessDryRun(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + req, _ := http.NewRequest("GET", "/override?s=song&a=artist", nil) + req = req.WithContext(context.WithValue(req.Context(), apiKeyAuthenticatedKey, true)) + rr := httptest.NewRecorder() + overrideHandler(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400 when id missing and not dry_run, got %d", rr.Code) + } + + var body map[string]interface{} + json.Unmarshal(rr.Body.Bytes(), &body) + if !strings.Contains(body["error"].(string), "id parameter") { + t.Errorf("Expected error about missing id, got %q", body["error"]) + } +} + +func TestOverrideHandler_DryRunFindsMatchingKeys(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + // Populate cache with entries + setCachedLyrics("ttml_lyrics:viva la vida coldplay", "old", 242000, 0.9, "en", false) + setCachedLyrics("ttml_lyrics:viva la vida coldplay 242s", "old with dur", 242000, 0.9, "en", false) + setCachedLyrics("ttml_lyrics:other song other artist", "unrelated", 200000, 0.8, "en", false) + + req, _ := http.NewRequest("GET", "/override?s=viva+la+vida&a=coldplay&dry_run=true", nil) + req = req.WithContext(context.WithValue(req.Context(), apiKeyAuthenticatedKey, true)) + rr := httptest.NewRecorder() + overrideHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var body map[string]interface{} + json.Unmarshal(rr.Body.Bytes(), &body) + + if body["dry_run"] != true { + t.Error("Expected dry_run=true in response") + } + + count := int(body["count"].(float64)) + if count != 2 { + t.Errorf("Expected 2 matching keys, got %d", count) + } +} + +func TestOverrideHandler_DryRunWithAlbumFilter(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + setCachedLyrics("ttml_lyrics:viva la vida coldplay", "no album", 242000, 0.9, "", false) + setCachedLyrics("ttml_lyrics:viva la vida coldplay viva la vida or death and all his friends", "with album", 242000, 0.9, "", false) + + req, _ := http.NewRequest("GET", "/override?s=viva+la+vida&a=coldplay&al=viva+la+vida+or+death+and+all+his+friends&dry_run=true", nil) + req = req.WithContext(context.WithValue(req.Context(), apiKeyAuthenticatedKey, true)) + rr := httptest.NewRecorder() + overrideHandler(rr, req) + + var body map[string]interface{} + json.Unmarshal(rr.Body.Bytes(), &body) + + count := int(body["count"].(float64)) + if count != 1 { + t.Errorf("Expected 1 matching key with album filter, got %d", count) + } +} + +func TestOverrideHandler_DryRunWithDurationFilter(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + setCachedLyrics("ttml_lyrics:viva la vida coldplay 242s", "242", 242000, 0.9, "", false) + setCachedLyrics("ttml_lyrics:viva la vida coldplay 300s", "300", 300000, 0.9, "", false) + + // Duration 243 with default 2s tolerance should match 242s but not 300s + req, _ := http.NewRequest("GET", "/override?s=viva+la+vida&a=coldplay&d=243&dry_run=true", nil) + req = req.WithContext(context.WithValue(req.Context(), apiKeyAuthenticatedKey, true)) + rr := httptest.NewRecorder() + overrideHandler(rr, req) + + var body map[string]interface{} + json.Unmarshal(rr.Body.Bytes(), &body) + + count := int(body["count"].(float64)) + if count != 1 { + t.Errorf("Expected 1 matching key with duration filter, got %d", count) + } +} + +func TestOverrideHandler_NoMatchCreatesNewEntry(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + // We can't call the real FetchLyricsByTrackID without configured accounts, + // so we verify the handler returns an error about no accounts (proving it + // attempted to fetch rather than returning 404) + req, _ := http.NewRequest("GET", "/override?id=123&s=nonexistent&a=nobody", nil) + req = req.WithContext(context.WithValue(req.Context(), apiKeyAuthenticatedKey, true)) + rr := httptest.NewRecorder() + overrideHandler(rr, req) + + // Should attempt to fetch (and fail due to no accounts), not 404 + if rr.Code == http.StatusNotFound { + t.Error("Should not return 404 when no cache matches — should attempt fetch and create") + } + + var body map[string]interface{} + json.Unmarshal(rr.Body.Bytes(), &body) + // The error should be about fetching, not about "no matching cache entries" + if errMsg, ok := body["error"].(string); ok { + if strings.Contains(errMsg, "no matching cache entries") { + t.Errorf("Should not return 'no matching cache entries' — got: %s", errMsg) + } + } +} + +func TestOverrideHandler_DryRunDoesNotRequireTrackID(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + // dry_run=true should work even without id parameter + req, _ := http.NewRequest("GET", "/override?s=song&a=artist&dry_run=true", nil) + req = req.WithContext(context.WithValue(req.Context(), apiKeyAuthenticatedKey, true)) + rr := httptest.NewRecorder() + overrideHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200 for dry_run without id, got %d", rr.Code) + } +} diff --git a/routes.go b/routes.go index 7ffbd6b..0b1a562 100644 --- a/routes.go +++ b/routes.go @@ -12,6 +12,9 @@ func setupRoutes(router *mux.Router) { // Revalidate endpoint - checks if cached lyrics are stale and updates if needed router.HandleFunc("/revalidate", revalidateHandler) + // Override endpoint - replace cached lyrics with content fetched by Apple Music track ID + router.HandleFunc("/override", overrideHandler) + // Provider-specific endpoints - return {"lyrics": ..., "provider": ...} router.HandleFunc("/ttml/getLyrics", getLyricsWithProvider("ttml")) router.HandleFunc("/kugou/getLyrics", getLyricsWithProvider("kugou")) diff --git a/services/providers/ttml/ttml.go b/services/providers/ttml/ttml.go index 77b4b5f..62eb57d 100644 --- a/services/providers/ttml/ttml.go +++ b/services/providers/ttml/ttml.go @@ -7,6 +7,50 @@ import ( log "github.com/sirupsen/logrus" ) +// FetchLyricsByTrackID fetches TTML lyrics directly by Apple Music track ID, skipping search. +// Used by the /override endpoint to correct cached lyrics with a known-good track ID. +func FetchLyricsByTrackID(trackID string) (string, error) { + if accountManager == nil { + initAccountManager() + } + + if !accountManager.hasAccounts() { + return "", fmt.Errorf("no TTML accounts configured") + } + + if apiCircuitBreaker == nil { + initCircuitBreaker() + } + if apiCircuitBreaker.IsOpen() { + timeUntilRetry := apiCircuitBreaker.TimeUntilRetry() + if timeUntilRetry > 0 { + return "", fmt.Errorf("circuit breaker is open, API temporarily unavailable (retry in %v)", timeUntilRetry) + } + } + + account := accountManager.getNextAccount() + storefront := account.Storefront + if storefront == "" { + storefront = "us" + } + + log.Infof("%s Fetching lyrics by track ID %s via %s", logcolors.LogRequest, trackID, logcolors.Account(account.NameID)) + + ttml, err := fetchLyricsTTML(trackID, storefront, account) + if err != nil { + return "", fmt.Errorf("failed to fetch TTML for track %s: %v", trackID, err) + } + + if ttml == "" { + return "", fmt.Errorf("TTML content is empty for track %s", trackID) + } + + log.Infof("%s Fetched TTML by track ID %s via %s (%d bytes)", + logcolors.LogSuccess, trackID, logcolors.Account(account.NameID), len(ttml)) + + return ttml, nil +} + // FetchTTMLLyrics is the main function to fetch TTML API lyrics // durationMs is optional (0 means no duration filter), used to find closest matching track by duration // Returns: raw TTML string, track duration in ms, similarity score, track metadata, error From 3290e55063ba7ab8b5a4e80a2786e694039ec319 Mon Sep 17 00:00:00 2001 From: Boidushya Date: Wed, 11 Mar 2026 21:11:42 +0530 Subject: [PATCH 2/3] fix: number check --- handlers.go | 11 +++++++++++ main_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/handlers.go b/handlers.go index b5a161c..2da12ce 100644 --- a/handlers.go +++ b/handlers.go @@ -12,6 +12,7 @@ import ( "lyrics-api-go/services/providers" "lyrics-api-go/stats" "net/http" + "strconv" "strings" "time" @@ -1074,6 +1075,16 @@ func overrideHandler(w http.ResponseWriter, r *http.Request) { return } + // Validate track ID is numeric (Apple Music IDs are always numeric) + if trackID != "" { + if _, err := strconv.Atoi(trackID); err != nil { + Respond(w, r).Error(http.StatusBadRequest, map[string]interface{}{ + "error": "id must be a numeric Apple Music track ID", + }) + return + } + } + // 4. Find matching cache keys matchString := strings.ToLower(strings.TrimSpace(songName)) + " " + strings.ToLower(strings.TrimSpace(artistName)) normalizedAlbum := strings.ToLower(strings.TrimSpace(albumName)) diff --git a/main_test.go b/main_test.go index 6d49029..f405923 100644 --- a/main_test.go +++ b/main_test.go @@ -937,6 +937,40 @@ func TestOverrideHandler_NoMatchCreatesNewEntry(t *testing.T) { } } +func TestOverrideHandler_RejectsNonNumericTrackID(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + tests := []struct { + name string + trackID string + }{ + {"path traversal", "../1234"}, + {"query injection", "1234?foo=bar"}, + {"letters", "abc"}, + {"mixed", "123abc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, _ := http.NewRequest("GET", "/override?id="+tt.trackID+"&s=song&a=artist", nil) + req = req.WithContext(context.WithValue(req.Context(), apiKeyAuthenticatedKey, true)) + rr := httptest.NewRecorder() + overrideHandler(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for track ID %q, got %d", tt.trackID, rr.Code) + } + + var body map[string]interface{} + json.Unmarshal(rr.Body.Bytes(), &body) + if !strings.Contains(body["error"].(string), "numeric") { + t.Errorf("Expected error about numeric ID, got %q", body["error"]) + } + }) + } +} + func TestOverrideHandler_DryRunDoesNotRequireTrackID(t *testing.T) { cleanup := setupTestEnvironment(t) defer cleanup() From a4fb72047f5f2c06acc610983ad07ff42c9fec9a Mon Sep 17 00:00:00 2001 From: Boidushya Date: Wed, 11 Mar 2026 21:30:24 +0530 Subject: [PATCH 3/3] fix: dont load the entire fricking db in memory for override --- cache_helpers.go | 54 +++++++++++++++++++ handlers.go | 132 +++++++++++++++++++++++------------------------ main_test.go | 122 ++++++++++++++++++++++++++++++++++++++++++- types.go | 4 ++ 4 files changed, 244 insertions(+), 68 deletions(-) diff --git a/cache_helpers.go b/cache_helpers.go index 349424e..9ae8407 100644 --- a/cache_helpers.go +++ b/cache_helpers.go @@ -314,6 +314,60 @@ func buildLegacyCacheKey(songName, artistName, albumName, durationStr string) st return fmt.Sprintf("ttml_lyrics:%s", query) } +// findMatchingCacheKeys finds cache keys that match the given song/artist/album/duration +// using direct key lookups instead of scanning the entire cache. +// This is O(delta) instead of O(n) where n is the total number of cache entries. +func findMatchingCacheKeys(songName, artistName, albumName, durationStr string) []string { + seen := make(map[string]bool) + var keys []string + + addIfExists := func(key string) { + if seen[key] { + return + } + seen[key] = true + if _, ok := getCachedLyrics(key); ok { + keys = append(keys, key) + } + } + + // Try exact normalized key + addIfExists(buildNormalizedCacheKey(songName, artistName, albumName, durationStr)) + + // Try legacy key + addIfExists(buildLegacyCacheKey(songName, artistName, albumName, durationStr)) + + // Try without duration (same song/artist/album but stored without duration) + if durationStr != "" { + addIfExists(buildNormalizedCacheKey(songName, artistName, albumName, "")) + addIfExists(buildLegacyCacheKey(songName, artistName, albumName, "")) + } + + // Try nearby durations (fuzzy matching within ±delta) + if durationStr != "" { + var durationSec int + if _, err := fmt.Sscanf(durationStr, "%d", &durationSec); err == nil { + deltaMs := conf.Configuration.DurationMatchDeltaMs + deltaSec := deltaMs / 1000 + if deltaSec < 1 { + deltaSec = 1 + } + for offset := 1; offset <= deltaSec; offset++ { + if durationSec-offset >= 0 { + d := fmt.Sprintf("%d", durationSec-offset) + addIfExists(buildNormalizedCacheKey(songName, artistName, albumName, d)) + addIfExists(buildLegacyCacheKey(songName, artistName, albumName, d)) + } + d := fmt.Sprintf("%d", durationSec+offset) + addIfExists(buildNormalizedCacheKey(songName, artistName, albumName, d)) + addIfExists(buildLegacyCacheKey(songName, artistName, albumName, d)) + } + } + } + + return keys +} + // buildFallbackCacheKeys returns a list of cache keys to try when the backend fails. // Keys are ordered from most specific to least specific, excluding the original key. // When duration is provided, fallback keys still include duration to maintain strict matching. diff --git a/handlers.go b/handlers.go index 2da12ce..c100ede 100644 --- a/handlers.go +++ b/handlers.go @@ -53,6 +53,15 @@ func getLyrics(w http.ResponseWriter, r *http.Request) { // Check cache first with fuzzy duration matching (handles normalized + legacy keys) // This allows cache hits when duration differs by up to DURATION_MATCH_DELTA_MS (default 2s) if cached, foundKey, ok := getCachedLyricsWithDurationTolerance(songName, artistName, albumName, durationStr); ok { + // Check for no-lyrics sentinel — return 404 as if no lyrics exist + if cached.TTML == NoLyricsSentinel { + stats.Get().RecordCacheHit() + log.Infof("%s No-lyrics marker found for: %s", logcolors.LogCacheLyrics, query) + Respond(w, r).SetCacheStatus("HIT").Error(http.StatusNotFound, map[string]interface{}{ + "error": "No lyrics available for this track", + }) + return + } stats.Get().RecordCacheHit() if foundKey != cacheKey { log.Infof("%s Found cached TTML via fuzzy duration match: %s", logcolors.LogCacheLyrics, foundKey) @@ -257,6 +266,15 @@ func getLyricsWithProvider(providerName string) http.HandlerFunc { // Check cache first if cached, ok := getCachedLyrics(cacheKey); ok { + // Check for no-lyrics sentinel — return 404 as if no lyrics exist + if cached.TTML == NoLyricsSentinel { + stats.Get().RecordCacheHit() + log.Infof("%s [%s] No-lyrics marker found", logcolors.LogCacheLyrics, providerName) + Respond(w, r).SetProvider(providerName).SetCacheStatus("HIT").Error(http.StatusNotFound, map[string]interface{}{ + "error": "No lyrics available for this track", + }) + return + } stats.Get().RecordCacheHit() log.Infof("%s [%s] Found cached lyrics", logcolors.LogCacheLyrics, providerName) Respond(w, r).SetProvider(providerName).SetCacheStatus("HIT").JSON(map[string]interface{}{ @@ -1059,6 +1077,7 @@ func overrideHandler(w http.ResponseWriter, r *http.Request) { albumName := r.URL.Query().Get("al") + r.URL.Query().Get("album") + r.URL.Query().Get("albumName") durationStr := r.URL.Query().Get("d") + r.URL.Query().Get("duration") dryRun := r.URL.Query().Get("dry_run") == "true" + noLyrics := r.URL.Query().Get("no_lyrics") == "true" // 3. Validate required params if songName == "" || artistName == "" { @@ -1068,7 +1087,7 @@ func overrideHandler(w http.ResponseWriter, r *http.Request) { return } - if trackID == "" && !dryRun { + if trackID == "" && !dryRun && !noLyrics { Respond(w, r).Error(http.StatusBadRequest, map[string]interface{}{ "error": "id parameter is required (Apple Music track ID)", }) @@ -1085,79 +1104,57 @@ func overrideHandler(w http.ResponseWriter, r *http.Request) { } } - // 4. Find matching cache keys - matchString := strings.ToLower(strings.TrimSpace(songName)) + " " + strings.ToLower(strings.TrimSpace(artistName)) - normalizedAlbum := strings.ToLower(strings.TrimSpace(albumName)) - - var durationSec int - hasDuration := false - if durationStr != "" { - if _, err := fmt.Sscanf(durationStr, "%d", &durationSec); err == nil { - hasDuration = true - } - } + // 4. Find matching cache keys using direct lookups (avoids full cache scan) + matchingKeys := findMatchingCacheKeys(songName, artistName, albumName, durationStr) - deltaMs := conf.Configuration.DurationMatchDeltaMs - deltaSec := deltaMs / 1000 - if deltaSec < 1 { - deltaSec = 1 + // 5. Dry run: return matching keys without modifying anything + if dryRun { + query := strings.ToLower(strings.TrimSpace(songName)) + " " + strings.ToLower(strings.TrimSpace(artistName)) + log.Infof("%s Dry run: found %d matching keys for %s", logcolors.LogOverride, len(matchingKeys), query) + Respond(w, r).JSON(map[string]interface{}{ + "dry_run": true, + "count": len(matchingKeys), + "keys": matchingKeys, + }) + return } - var matchingKeys []string - persistentCache.Range(func(key string, entry cache.CacheEntry) bool { - if !strings.HasPrefix(key, "ttml_lyrics:") { - return true - } - - query := strings.TrimPrefix(key, "ttml_lyrics:") - - // Key must contain the song+artist match string - if !strings.Contains(query, matchString) { - return true - } + // 6. Handle no_lyrics mode: store sentinel to permanently mark as "no lyrics" + if noLyrics { + var updatedKeys []string + created := false - // If album provided, key must contain normalized album name - if normalizedAlbum != "" && !strings.Contains(query, normalizedAlbum) { - return true - } - - // If duration provided, extract duration from key and check within ±delta - if hasDuration { - var keyDuration int - // Cache key format: "song artist [album] [123s]" - if idx := strings.LastIndex(query, " "); idx != -1 { - suffix := query[idx+1:] - if strings.HasSuffix(suffix, "s") { - fmt.Sscanf(strings.TrimSuffix(suffix, "s"), "%d", &keyDuration) - } - } - if keyDuration > 0 { - diff := keyDuration - durationSec - if diff < 0 { - diff = -diff - } - if diff > deltaSec { - return true + if len(matchingKeys) == 0 { + cacheKey := buildNormalizedCacheKey(songName, artistName, albumName, durationStr) + setCachedLyrics(cacheKey, NoLyricsSentinel, 0, 0, "", false) + updatedKeys = append(updatedKeys, cacheKey) + created = true + log.Infof("%s Created no_lyrics marker for %s", logcolors.LogOverride, cacheKey) + } else { + for _, key := range matchingKeys { + cached, ok := getCachedLyrics(key) + if !ok { + continue } + setCachedLyrics(key, NoLyricsSentinel, cached.TrackDurationMs, cached.Score, cached.Language, cached.IsRTL) + updatedKeys = append(updatedKeys, key) } + log.Infof("%s Set no_lyrics marker on %d cache entries", logcolors.LogOverride, len(updatedKeys)) } - matchingKeys = append(matchingKeys, key) - return true - }) + // Clear any negative cache entries for this query + deleteNegativeCache(buildNormalizedCacheKey(songName, artistName, albumName, durationStr)) - // 5. Dry run: return matching keys without modifying anything - if dryRun { - log.Infof("%s Dry run: found %d matching keys for %s", logcolors.LogOverride, len(matchingKeys), matchString) Respond(w, r).JSON(map[string]interface{}{ - "dry_run": true, - "count": len(matchingKeys), - "keys": matchingKeys, + "updated": len(updatedKeys), + "created": created, + "keys": updatedKeys, + "no_lyrics": true, }) return } - // 6. Fetch lyrics by track ID + // 7. Fetch lyrics by track ID log.Infof("%s Fetching lyrics for track ID %s to override %d cache entries", logcolors.LogOverride, trackID, len(matchingKeys)) ttmlString, err := ttml.FetchLyricsByTrackID(trackID) if err != nil { @@ -1169,7 +1166,7 @@ func overrideHandler(w http.ResponseWriter, r *http.Request) { return } - // 7. Update matching cache entries, or create a new one if none exist + // 8. Update matching cache entries, or create a new one if none exist var updatedKeys []string created := false @@ -1201,7 +1198,7 @@ func overrideHandler(w http.ResponseWriter, r *http.Request) { log.Infof("%s Updated %d cache entries with lyrics from track ID %s", logcolors.LogOverride, len(updatedKeys), trackID) } - // 8. Clear any negative cache entries for this query + // 9. Clear any negative cache entries for this query deleteNegativeCache(buildNormalizedCacheKey(songName, artistName, albumName, durationStr)) Respond(w, r).JSON(map[string]interface{}{ @@ -1274,6 +1271,9 @@ func revalidateHandler(w http.ResponseWriter, r *http.Request) { usedKey = legacyCacheKey } + // Treat no-lyrics sentinel entries like negative cache (allow revalidation to replace them) + wasNoLyricsSentinel := found && cached.TTML == NoLyricsSentinel + // Check if this was in negative cache (allows revalidation of "no lyrics" entries) wasInNegativeCache := false if !found { @@ -1298,9 +1298,9 @@ func revalidateHandler(w http.ResponseWriter, r *http.Request) { return } - // 5. Compute hash of cached content (empty if from negative cache) + // 5. Compute hash of cached content (empty if from negative cache or no-lyrics sentinel) var oldHash [16]byte - if !wasInNegativeCache { + if !wasInNegativeCache && !wasNoLyricsSentinel { oldHash = md5.Sum([]byte(cached.TTML)) } @@ -1333,9 +1333,9 @@ func revalidateHandler(w http.ResponseWriter, r *http.Request) { return } - // 7. Compare hashes (if from negative cache, always treat as updated) + // 7. Compare hashes (if from negative cache or no-lyrics sentinel, always treat as updated) newHash := md5.Sum([]byte(ttmlString)) - updated := wasInNegativeCache || oldHash != newHash + updated := wasInNegativeCache || wasNoLyricsSentinel || oldHash != newHash if updated { // Delete negative cache if it existed diff --git a/main_test.go b/main_test.go index f405923..7249a80 100644 --- a/main_test.go +++ b/main_test.go @@ -845,6 +845,7 @@ func TestOverrideHandler_DryRunFindsMatchingKeys(t *testing.T) { setCachedLyrics("ttml_lyrics:viva la vida coldplay 242s", "old with dur", 242000, 0.9, "en", false) setCachedLyrics("ttml_lyrics:other song other artist", "unrelated", 200000, 0.8, "en", false) + // Without duration: only finds the no-duration key req, _ := http.NewRequest("GET", "/override?s=viva+la+vida&a=coldplay&dry_run=true", nil) req = req.WithContext(context.WithValue(req.Context(), apiKeyAuthenticatedKey, true)) rr := httptest.NewRecorder() @@ -862,8 +863,26 @@ func TestOverrideHandler_DryRunFindsMatchingKeys(t *testing.T) { } count := int(body["count"].(float64)) - if count != 2 { - t.Errorf("Expected 2 matching keys, got %d", count) + if count != 1 { + t.Errorf("Expected 1 matching key (no-duration key only), got %d", count) + } + + // With duration: finds both no-duration and duration keys + req2, _ := http.NewRequest("GET", "/override?s=viva+la+vida&a=coldplay&d=242&dry_run=true", nil) + req2 = req2.WithContext(context.WithValue(req2.Context(), apiKeyAuthenticatedKey, true)) + rr2 := httptest.NewRecorder() + overrideHandler(rr2, req2) + + if rr2.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rr2.Code, rr2.Body.String()) + } + + var body2 map[string]interface{} + json.Unmarshal(rr2.Body.Bytes(), &body2) + + count2 := int(body2["count"].(float64)) + if count2 != 2 { + t.Errorf("Expected 2 matching keys (with duration), got %d", count2) } } @@ -985,3 +1004,102 @@ func TestOverrideHandler_DryRunDoesNotRequireTrackID(t *testing.T) { t.Errorf("Expected 200 for dry_run without id, got %d", rr.Code) } } + +func TestOverrideHandler_NoLyricsSetsMarker(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + req, _ := http.NewRequest("GET", "/override?s=instrumental+song&a=some+artist&no_lyrics=true", nil) + req = req.WithContext(context.WithValue(req.Context(), apiKeyAuthenticatedKey, true)) + rr := httptest.NewRecorder() + overrideHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var body map[string]interface{} + json.Unmarshal(rr.Body.Bytes(), &body) + + if body["no_lyrics"] != true { + t.Error("Expected no_lyrics=true in response") + } + + // Verify the sentinel was stored in cache + cacheKey := buildNormalizedCacheKey("instrumental song", "some artist", "", "") + cached, ok := getCachedLyrics(cacheKey) + if !ok { + t.Fatal("Expected cache entry to exist") + } + if cached.TTML != NoLyricsSentinel { + t.Errorf("Expected TTML to be %q, got %q", NoLyricsSentinel, cached.TTML) + } +} + +func TestOverrideHandler_NoLyricsOverwritesExisting(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + // Pre-populate cache with real lyrics + setCachedLyrics("ttml_lyrics:my song my artist", "real lyrics", 200000, 0.9, "en", false) + + req, _ := http.NewRequest("GET", "/override?s=my+song&a=my+artist&no_lyrics=true", nil) + req = req.WithContext(context.WithValue(req.Context(), apiKeyAuthenticatedKey, true)) + rr := httptest.NewRecorder() + overrideHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + // Verify the sentinel replaced the real lyrics + cached, ok := getCachedLyrics("ttml_lyrics:my song my artist") + if !ok { + t.Fatal("Expected cache entry to exist") + } + if cached.TTML != NoLyricsSentinel { + t.Errorf("Expected TTML to be %q, got %q", NoLyricsSentinel, cached.TTML) + } + // Metadata should be preserved + if cached.TrackDurationMs != 200000 { + t.Errorf("Expected duration to be preserved (200000), got %d", cached.TrackDurationMs) + } +} + +func TestOverrideHandler_NoLyricsDoesNotRequireTrackID(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + // no_lyrics=true should work without id parameter + req, _ := http.NewRequest("GET", "/override?s=song&a=artist&no_lyrics=true", nil) + req = req.WithContext(context.WithValue(req.Context(), apiKeyAuthenticatedKey, true)) + rr := httptest.NewRecorder() + overrideHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200 for no_lyrics without id, got %d", rr.Code) + } +} + +func TestGetLyrics_NoLyricsSentinelReturns404(t *testing.T) { + cleanup := setupTestEnvironment(t) + defer cleanup() + + // Store a no-lyrics sentinel + cacheKey := buildNormalizedCacheKey("instrumental", "artist", "", "") + setCachedLyrics(cacheKey, NoLyricsSentinel, 0, 0, "", false) + + req, _ := http.NewRequest("GET", "/getLyrics?s=instrumental&a=artist", nil) + rr := httptest.NewRecorder() + getLyrics(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("Expected 404 for no-lyrics sentinel, got %d: %s", rr.Code, rr.Body.String()) + } + + var body map[string]interface{} + json.Unmarshal(rr.Body.Bytes(), &body) + if !strings.Contains(body["error"].(string), "No lyrics available") { + t.Errorf("Expected 'No lyrics available' error, got %q", body["error"]) + } +} diff --git a/types.go b/types.go index 846327c..39e9dc3 100644 --- a/types.go +++ b/types.go @@ -15,6 +15,10 @@ const ( apiKeyInvalidKey contextKey = "apiKeyInvalid" ) +// NoLyricsSentinel is stored as TTML content to permanently mark a track as having no lyrics. +// Unlike negative cache entries (which expire), this is stored in the positive cache and persists indefinitely. +const NoLyricsSentinel = "__NO_LYRICS__" + // CacheDump represents the full cache contents type CacheDump map[string]cache.CacheEntry