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