feat: autotag scoring overhaul, dump-based explore index, and lyrics search
Consolidates in-progress work across autotag, explore, and library: - autotag: beets/Picard-informed scoring engine — ID-first matching, VA handling, recommendation tiers, and a merged distance/rank cascade, with an eval harness for regression tracking. - explore: offline MusicBrainz dump import/incremental refresh replaces the legacy tier crawl; index-first local search with fuzzy matching and a dedicated ranker; disk-free guards for dump downloads. - library: artist-credit extraction and matching. - lyrics: owned-library lyric search (FTS) with LRCLIB backfill. Also: rewrite README to be user-focused, and migrate upstream to git.ljones.me/yonlu/yellowjacket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// lrclibBaseURL is the LRCLIB lyrics API. LRCLIB is a free,
|
||||
// community-maintained lyrics database (plain + synced) with no
|
||||
// auth required.
|
||||
lrclibBaseURL = "https://lrclib.net"
|
||||
|
||||
// lrclibUserAgent identifies the app per LRCLIB's guidelines.
|
||||
lrclibUserAgent = "YellowJacket (https://github.com/yellowjacket)"
|
||||
|
||||
// lrclibRate is the requests-per-second budget for LRCLIB. The
|
||||
// service has no hard published limit but asks clients to be
|
||||
// gentle; this keeps the library backfill polite.
|
||||
lrclibRate = 3
|
||||
)
|
||||
|
||||
// ErrLyricsNotFound is returned when LRCLIB has no match for the
|
||||
// requested track.
|
||||
var ErrLyricsNotFound = errors.New("lyrics not found")
|
||||
|
||||
// Lyrics holds the plain and (optional) time-synced lyrics for a
|
||||
// track, plus whether the track is marked instrumental.
|
||||
type Lyrics struct {
|
||||
Plain string `json:"plain"`
|
||||
Synced string `json:"synced"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
}
|
||||
|
||||
// LRCLibClient is a thin, rate-limited, cached HTTP client for the
|
||||
// LRCLIB lyrics API.
|
||||
type LRCLibClient struct {
|
||||
http *http.Client
|
||||
limiter *RateLimiter
|
||||
cache *Cache
|
||||
logger *slog.Logger
|
||||
baseURL string // overridable in tests
|
||||
}
|
||||
|
||||
// NewLRCLibClient creates an LRCLIB client sharing the given cache.
|
||||
func NewLRCLibClient(cache *Cache, logger *slog.Logger) *LRCLibClient {
|
||||
return &LRCLibClient{
|
||||
http: &http.Client{Timeout: 20 * time.Second},
|
||||
limiter: NewRateLimiterN(lrclibRate),
|
||||
cache: cache,
|
||||
logger: logger,
|
||||
baseURL: lrclibBaseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// lrclibResponse is the wire shape of LRCLIB's /api/get response.
|
||||
type lrclibResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Duration float64 `json:"duration"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
PlainLyrics string `json:"plainLyrics"`
|
||||
SyncedLyrics string `json:"syncedLyrics"`
|
||||
}
|
||||
|
||||
// GetLyrics fetches lyrics for a track by artist, title, album, and
|
||||
// duration (seconds; pass 0 if unknown). LRCLIB matches on the
|
||||
// metadata with a small duration tolerance. Returns ErrLyricsNotFound
|
||||
// when no match exists. Successful and negative results are both
|
||||
// cached so a repeated backfill doesn't re-hit the network.
|
||||
func (c *LRCLibClient) GetLyrics(
|
||||
ctx context.Context, artist, title, album string, durationSec int,
|
||||
) (*Lyrics, error) {
|
||||
if artist == "" || title == "" {
|
||||
return nil, ErrLyricsNotFound
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("artist_name", artist)
|
||||
q.Set("track_name", title)
|
||||
|
||||
if album != "" {
|
||||
q.Set("album_name", album)
|
||||
}
|
||||
|
||||
if durationSec > 0 {
|
||||
q.Set("duration", strconv.Itoa(durationSec))
|
||||
}
|
||||
|
||||
reqURL := c.baseURL + "/api/get?" + q.Encode()
|
||||
cacheKey := "lrclib:get:" + q.Encode()
|
||||
|
||||
if data, ok := c.cache.Get(cacheKey); ok {
|
||||
return decodeCachedLyrics(data)
|
||||
}
|
||||
|
||||
body, status, err := c.doGet(ctx, reqURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lrclib get: %w", err)
|
||||
}
|
||||
|
||||
if status == http.StatusNotFound {
|
||||
// Cache the miss as a sentinel so we don't re-request it.
|
||||
c.cache.Set(cacheKey, []byte(lyricsMissSentinel), cacheTTLSearch, "", "lyrics")
|
||||
|
||||
return nil, ErrLyricsNotFound
|
||||
}
|
||||
|
||||
if status < 200 || status >= 300 {
|
||||
return nil, fmt.Errorf("lrclib get: %w: status %d", ErrListenBrainzHTTP, status)
|
||||
}
|
||||
|
||||
var wire lrclibResponse
|
||||
if err := json.Unmarshal(body, &wire); err != nil {
|
||||
return nil, fmt.Errorf("lrclib get unmarshal: %w", err)
|
||||
}
|
||||
|
||||
lyrics := &Lyrics{
|
||||
Plain: wire.PlainLyrics,
|
||||
Synced: wire.SyncedLyrics,
|
||||
Instrumental: wire.Instrumental,
|
||||
}
|
||||
|
||||
// Persist the normalized result (not the raw wire body) so the
|
||||
// cached shape matches what callers expect.
|
||||
if encoded, err := json.Marshal(lyrics); err == nil {
|
||||
c.cache.Set(cacheKey, encoded, cacheTTLSearch, "", "lyrics")
|
||||
}
|
||||
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// lyricsMissSentinel marks a cached "no lyrics found" result.
|
||||
const lyricsMissSentinel = "\x00miss"
|
||||
|
||||
// decodeCachedLyrics interprets a cached LRCLIB payload, mapping the
|
||||
// miss sentinel back to ErrLyricsNotFound.
|
||||
func decodeCachedLyrics(data []byte) (*Lyrics, error) {
|
||||
if string(data) == lyricsMissSentinel {
|
||||
return nil, ErrLyricsNotFound
|
||||
}
|
||||
|
||||
var lyrics Lyrics
|
||||
if err := json.Unmarshal(data, &lyrics); err != nil {
|
||||
return nil, fmt.Errorf("lrclib cache decode: %w", err)
|
||||
}
|
||||
|
||||
return &lyrics, nil
|
||||
}
|
||||
|
||||
// doGet performs a rate-limited GET and returns the body and status.
|
||||
// Unlike the ListenBrainz client, a 404 is a normal "no lyrics"
|
||||
// outcome, so the status is returned rather than folded into an error.
|
||||
func (c *LRCLibClient) doGet(ctx context.Context, reqURL string) ([]byte, int, error) {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, 0, fmt.Errorf("rate limiter: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", lrclibUserAgent)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
Reference in New Issue
Block a user