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..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"
@@ -1037,6 +1038,180 @@ 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
+ }
+
+ // 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))
+
+ 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..f405923 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,216 @@ 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_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()
+
+ // 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