From b7cd955897163cd2bee79144048b5454562d38ac Mon Sep 17 00:00:00 2001 From: Boidushya Date: Wed, 11 Mar 2026 20:54:51 +0530 Subject: [PATCH 1/2] 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/2] 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()