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