diff --git a/.env.example b/.env.example index fc421cf..13ae1fb 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,10 @@ FF_CACHE_ONLY_MODE=false #NOTIFIER_NTFY_TOPIC=my_unique_topic_name #NOTIFIER_NTFY_SERVER=https://ntfy.sh +# External Lyrics API (binimum.org) - posts lyrics data for archival +#BINI_API_KEY=your_bini_api_key_here +#BINI_API_URL=https://lyrics-api.binimum.org/ + # Legacy Provider Configuration (for /legacy/getLyrics endpoint) # These are required only if using the legacy Spotify-based provider #LYRICS_URL= diff --git a/config/config.go b/config/config.go index 6473d63..8837e53 100644 --- a/config/config.go +++ b/config/config.go @@ -27,6 +27,8 @@ type Config struct { CacheAccessToken string `envconfig:"CACHE_ACCESS_TOKEN" default:""` APIKey string `envconfig:"API_KEY" default:""` APIKeyRequired bool `envconfig:"API_KEY_REQUIRED" default:"false"` + BiniAPIKey string `envconfig:"BINI_API_KEY" default:""` + BiniAPIURL string `envconfig:"BINI_API_URL" default:"https://lyrics-api.binimum.org/"` // TTML API Configuration // Token source for auto-scraping bearer tokens (web frontend URL) diff --git a/handlers.go b/handlers.go index 5a027c4..41e2539 100644 --- a/handlers.go +++ b/handlers.go @@ -7,6 +7,7 @@ import ( "fmt" "lyrics-api-go/cache" "lyrics-api-go/logcolors" + "lyrics-api-go/services/bini" "lyrics-api-go/services/notifier" "lyrics-api-go/services/providers" "lyrics-api-go/stats" @@ -152,7 +153,7 @@ func getLyrics(w http.ResponseWriter, r *http.Request) { durationMs = durationMs * 1000 // Convert seconds to milliseconds } - ttmlString, trackDurationMs, score, err := ttml.FetchTTMLLyrics(songName, artistName, albumName, durationMs) + ttmlString, trackDurationMs, score, trackMeta, err := ttml.FetchTTMLLyrics(songName, artistName, albumName, durationMs) req.err = err if err == nil { @@ -212,6 +213,8 @@ func getLyrics(w http.ResponseWriter, r *http.Request) { log.Infof("%s Caching TTML for: %s (trackDuration: %dms)", logcolors.LogCacheLyrics, query, trackDurationMs) setCachedLyrics(cacheKey, ttmlString, trackDurationMs, score, "", false) + go bini.PostLyrics(trackMeta.Name, trackMeta.ArtistName, trackMeta.AlbumName, trackDurationMs, ttmlString, trackMeta.ISRC) + Respond(w, r).SetCacheStatus("MISS").JSON(map[string]interface{}{ "ttml": ttmlString, "score": score, @@ -1134,7 +1137,7 @@ func revalidateHandler(w http.ResponseWriter, r *http.Request) { } log.Infof("%s Revalidating cache for: %s %s", logcolors.LogRevalidate, songName, artistName) - ttmlString, trackDurationMs, score, err := ttml.FetchTTMLLyrics(songName, artistName, albumName, durationMs) + ttmlString, trackDurationMs, score, trackMeta, err := ttml.FetchTTMLLyrics(songName, artistName, albumName, durationMs) if err != nil { log.Warnf("%s Revalidation fetch failed: %v", logcolors.LogRevalidate, err) @@ -1166,6 +1169,7 @@ func revalidateHandler(w http.ResponseWriter, r *http.Request) { } // Update cache with fresh content setCachedLyrics(usedKey, ttmlString, trackDurationMs, score, "", false) + go bini.PostLyrics(trackMeta.Name, trackMeta.ArtistName, trackMeta.AlbumName, trackDurationMs, ttmlString, trackMeta.ISRC) log.Infof("%s Content changed, cache updated for: %s", logcolors.LogRevalidate, usedKey) } else { log.Infof("%s Content unchanged for: %s", logcolors.LogRevalidate, usedKey) diff --git a/logcolors/colors.go b/logcolors/colors.go index e17a320..ead0745 100644 --- a/logcolors/colors.go +++ b/logcolors/colors.go @@ -104,3 +104,8 @@ const ( LogHealthCheck = Cyan + "[Health Check]" + Reset LogAccountInit = Cyan + "[Account Init]" + Reset ) + +// External API log prefixes +const ( + LogBini = Cyan + "[Bini]" + Reset +) diff --git a/services/bini/client.go b/services/bini/client.go new file mode 100644 index 0000000..8dad206 --- /dev/null +++ b/services/bini/client.go @@ -0,0 +1,74 @@ +package bini + +import ( + "bytes" + "encoding/json" + "io" + "lyrics-api-go/config" + "lyrics-api-go/logcolors" + "net/http" + "time" + + log "github.com/sirupsen/logrus" +) + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +type postLyricsPayload struct { + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` + AlbumName string `json:"album_name"` + OriginalSource string `json:"originalSource"` + Duration int `json:"duration"` + TTMLRaw string `json:"ttml_raw"` + ISRC string `json:"isrc,omitempty"` +} + +// PostLyrics sends lyrics data to the external Bini API. +// This is fire-and-forget; errors are logged but not returned. +func PostLyrics(trackName, artistName, albumName string, durationMs int, ttmlRaw, isrc string) { + cfg := config.Get() + if cfg.Configuration.BiniAPIKey == "" { + return + } + + payload := postLyricsPayload{ + TrackName: trackName, + ArtistName: artistName, + AlbumName: albumName, + OriginalSource: "Apple", + Duration: durationMs / 1000, + TTMLRaw: ttmlRaw, + ISRC: isrc, + } + + log.Infof("%s Payload: track=%q artist=%q album=%q duration=%d isrc=%q", logcolors.LogBini, trackName, artistName, albumName, payload.Duration, isrc) + + body, err := json.Marshal(payload) + if err != nil { + log.Errorf("%s Failed to marshal payload: %v", logcolors.LogBini, err) + return + } + + req, err := http.NewRequest("POST", cfg.Configuration.BiniAPIURL, bytes.NewReader(body)) + if err != nil { + log.Errorf("%s Failed to create request: %v", logcolors.LogBini, err) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", cfg.Configuration.BiniAPIKey) + + resp, err := httpClient.Do(req) + if err != nil { + log.Errorf("%s POST failed: %v", logcolors.LogBini, err) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + log.Infof("%s Posted lyrics for: %s - %s (ISRC: %s)", logcolors.LogBini, trackName, artistName, isrc) + } else { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + log.Warnf("%s POST returned status %d for: %s - %s | body: %s", logcolors.LogBini, resp.StatusCode, trackName, artistName, string(respBody)) + } +} diff --git a/services/providers/ttml/provider.go b/services/providers/ttml/provider.go index 95b88de..de7d924 100644 --- a/services/providers/ttml/provider.go +++ b/services/providers/ttml/provider.go @@ -35,7 +35,7 @@ func (p *TTMLProvider) CacheKeyPrefix() string { // FetchLyrics fetches lyrics from TTML API func (p *TTMLProvider) FetchLyrics(ctx context.Context, song, artist, album string, durationMs int) (*providers.LyricsResult, error) { // Use the existing FetchTTMLLyrics function - rawTTML, trackDurationMs, score, err := FetchTTMLLyrics(song, artist, album, durationMs) + rawTTML, trackDurationMs, score, _, err := FetchTTMLLyrics(song, artist, album, durationMs) if err != nil { return nil, providers.NewProviderError(ProviderName, "failed to fetch lyrics", err) } diff --git a/services/providers/ttml/ttml.go b/services/providers/ttml/ttml.go index 46db946..77b4b5f 100644 --- a/services/providers/ttml/ttml.go +++ b/services/providers/ttml/ttml.go @@ -9,14 +9,14 @@ import ( // 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, error -func FetchTTMLLyrics(songName, artistName, albumName string, durationMs int) (string, int, float64, error) { +// Returns: raw TTML string, track duration in ms, similarity score, track metadata, error +func FetchTTMLLyrics(songName, artistName, albumName string, durationMs int) (string, int, float64, *TrackMeta, error) { if accountManager == nil { initAccountManager() } if !accountManager.hasAccounts() { - return "", 0, 0.0, fmt.Errorf("no TTML accounts configured") + return "", 0, 0.0, nil, fmt.Errorf("no TTML accounts configured") } // Early-exit if circuit breaker is definitely open (avoid unnecessary work) @@ -28,7 +28,7 @@ func FetchTTMLLyrics(songName, artistName, albumName string, durationMs int) (st if apiCircuitBreaker.IsOpen() { timeUntilRetry := apiCircuitBreaker.TimeUntilRetry() if timeUntilRetry > 0 { - return "", 0, 0.0, fmt.Errorf("circuit breaker is open, API temporarily unavailable (retry in %v)", timeUntilRetry) + return "", 0, 0.0, nil, fmt.Errorf("circuit breaker is open, API temporarily unavailable (retry in %v)", timeUntilRetry) } // Cooldown passed - let it through, Allow() will handle the HALF-OPEN transition } @@ -41,7 +41,7 @@ func FetchTTMLLyrics(songName, artistName, albumName string, durationMs int) (st } if songName == "" && artistName == "" { - return "", 0, 0.0, fmt.Errorf("song name and artist name cannot both be empty") + return "", 0, 0.0, nil, fmt.Errorf("song name and artist name cannot both be empty") } query := songName + " " + artistName @@ -58,11 +58,11 @@ func FetchTTMLLyrics(songName, artistName, albumName string, durationMs int) (st // Search returns the account that succeeded (may differ if retry occurred) track, score, workingAccount, err := searchTrack(query, storefront, songName, artistName, albumName, durationMs, account) if err != nil { - return "", 0, 0.0, fmt.Errorf("search failed: %v", err) + return "", 0, 0.0, nil, fmt.Errorf("search failed: %v", err) } if track == nil { - return "", 0, 0.0, fmt.Errorf("no track found for query: %s", query) + return "", 0, 0.0, nil, fmt.Errorf("no track found for query: %s", query) } trackDurationMs := track.Attributes.DurationInMillis @@ -84,15 +84,20 @@ func FetchTTMLLyrics(songName, artistName, albumName string, durationMs int) (st // This ensures we don't hit a quarantined account ttml, err := fetchLyricsTTML(track.ID, storefront, workingAccount) if err != nil { - return "", 0, 0.0, fmt.Errorf("failed to fetch TTML: %v", err) + return "", 0, 0.0, nil, fmt.Errorf("failed to fetch TTML: %v", err) } if ttml == "" { - return "", 0, 0.0, fmt.Errorf("TTML content is empty") + return "", 0, 0.0, nil, fmt.Errorf("TTML content is empty") } log.Infof("%s Fetched TTML via %s for: %s - %s (%d bytes)", logcolors.LogSuccess, logcolors.Account(workingAccount.NameID), track.Attributes.Name, track.Attributes.ArtistName, len(ttml)) - return ttml, trackDurationMs, score, nil + return ttml, trackDurationMs, score, &TrackMeta{ + Name: track.Attributes.Name, + ArtistName: track.Attributes.ArtistName, + AlbumName: track.Attributes.AlbumName, + ISRC: track.Attributes.ISRC, + }, nil } diff --git a/services/providers/ttml/types.go b/services/providers/ttml/types.go index 18fc949..d5015f5 100644 --- a/services/providers/ttml/types.go +++ b/services/providers/ttml/types.go @@ -16,6 +16,14 @@ type Line = providers.Line // Syllable is an alias for the shared Syllable type type Syllable = providers.Syllable +// TrackMeta contains metadata about the matched track from Apple Music +type TrackMeta struct { + Name string + ArtistName string + AlbumName string + ISRC string +} + // ============================================================================= // ACCOUNT MANAGEMENT TYPES // =============================================================================