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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions logcolors/colors.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,8 @@ const (
LogHealthCheck = Cyan + "[Health Check]" + Reset
LogAccountInit = Cyan + "[Account Init]" + Reset
)

// External API log prefixes
const (
LogBini = Cyan + "[Bini]" + Reset
)
74 changes: 74 additions & 0 deletions services/bini/client.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
2 changes: 1 addition & 1 deletion services/providers/ttml/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
25 changes: 15 additions & 10 deletions services/providers/ttml/ttml.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}
8 changes: 8 additions & 0 deletions services/providers/ttml/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =============================================================================
Expand Down
Loading