Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions cache_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
132 changes: 66 additions & 66 deletions handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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{}{
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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)",
})
Expand All @@ -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 {
Expand All @@ -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

Expand Down Expand Up @@ -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{}{
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading