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:
2026-07-24 12:14:20 -04:00
co-authored by Claude Opus 4.8
parent d5140395da
commit 65048401e8
117 changed files with 17033 additions and 4767 deletions
+129 -8
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"unicode"
@@ -18,6 +19,12 @@ const (
// cacheTTLEntity is the TTL for lookup/browse results (entity data
// changes rarely).
cacheTTLEntity = 7 * 24 * time.Hour
// cacheTTLReleases is the TTL for a release group's releases +
// tracklists. This data is effectively immutable once published, so
// it's cached far longer than other entities: it's the local store
// that keeps an album page's cold fetch a once-per-quarter event
// rather than a weekly one.
cacheTTLReleases = 90 * 24 * time.Hour
)
// MusicBrainzClient wraps the musicbrainzws2 library with a local
@@ -360,15 +367,27 @@ func (c *MusicBrainzClient) LookupRelease(
return &out, nil
}
// BrowseReleases fetches the releases for a given release group
// MBID, including media/track information. Cached for 7 days.
func (c *MusicBrainzClient) BrowseReleases(
ctx context.Context, releaseGroupMBID string,
) ([]MBRelease, error) {
cacheKey := "mb:browse:releases:" + releaseGroupMBID
// MBRecordingRelease is a slim reference to one release a recording
// appears on — enough for the autotagger to pick a representative
// release and then LookupRelease it in full.
type MBRecordingRelease struct {
MBID string `json:"mbid"`
Title string `json:"title"`
Status string `json:"status"`
Date string `json:"date"`
}
// LookupRecordingReleases fetches the releases a recording appears on
// (id / title / status / date only). Used by the autotag recording-
// search path to resolve a picked recording to a concrete release.
// Cached for 7 days.
func (c *MusicBrainzClient) LookupRecordingReleases(
ctx context.Context, recordingMBID string,
) ([]MBRecordingRelease, error) {
cacheKey := "mb:lookup:recording-releases:" + recordingMBID
if data, ok := c.cache.Get(cacheKey); ok {
var out []MBRelease
var out []MBRecordingRelease
if err := json.Unmarshal(data, &out); err == nil {
return out, nil
}
@@ -378,6 +397,77 @@ func (c *MusicBrainzClient) BrowseReleases(
return nil, err
}
c.logger.Info("musicbrainz lookup recording releases", "mbid", recordingMBID)
rec, err := c.mb.LookupRecording(
ctx,
mbtypes.MBID(recordingMBID),
musicbrainzws2.IncludesFilter{Includes: []string{"releases"}},
)
if err != nil {
return nil, err
}
out := make([]MBRecordingRelease, 0, len(rec.Releases))
for _, rel := range rec.Releases {
out = append(out, MBRecordingRelease{
MBID: string(rel.ID),
Title: rel.Title,
Status: rel.Status,
Date: rel.Date.String(),
})
}
c.cacheJSON(cacheKey, out, cacheTTLEntity, recordingMBID, "recording")
return out, nil
}
// BrowseReleases fetches the releases for a given release group
// MBID, including media/track information. Cached for 7 days.
// browseReleasesCacheKey returns the response-cache key for a release
// group's releases.
func browseReleasesCacheKey(releaseGroupMBID string) string {
return "mb:browse:releases:" + releaseGroupMBID
}
// BrowseReleasesCached returns a release group's releases from the local
// response cache only, never hitting the network. The bool reports
// whether a fresh (unexpired) cache entry was found. Used by the album
// page's local-first path so a cold fetch can be deferred to the
// background instead of blocking the request.
func (c *MusicBrainzClient) BrowseReleasesCached(
releaseGroupMBID string,
) ([]MBRelease, bool) {
data, ok := c.cache.Get(browseReleasesCacheKey(releaseGroupMBID))
if !ok {
return nil, false
}
var out []MBRelease
if err := json.Unmarshal(data, &out); err != nil {
return nil, false
}
return out, true
}
// BrowseReleases fetches all releases (with recordings + media) for a
// release group, serving from the local response cache when warm and
// otherwise hitting MusicBrainz and caching the result.
func (c *MusicBrainzClient) BrowseReleases(
ctx context.Context, releaseGroupMBID string,
) ([]MBRelease, error) {
cacheKey := browseReleasesCacheKey(releaseGroupMBID)
if out, ok := c.BrowseReleasesCached(releaseGroupMBID); ok {
return out, nil
}
if err := c.limiter.Wait(ctx); err != nil {
return nil, err
}
c.logger.Info("musicbrainz browse releases",
"releaseGroupMBID", releaseGroupMBID,
)
@@ -395,7 +485,7 @@ func (c *MusicBrainzClient) BrowseReleases(
out := convertReleases(result.Releases)
c.cacheJSON(cacheKey, out, cacheTTLEntity, releaseGroupMBID, "release-group")
c.cacheJSON(cacheKey, out, cacheTTLReleases, releaseGroupMBID, "release-group")
return out, nil
}
@@ -534,7 +624,19 @@ func convertRelease(r musicbrainzws2.Release) MBRelease {
}
for _, m := range r.Media {
// Skip video media outright — DVD/Blu-ray bonus discs
// inflate track counts and wreck track-count-based scoring
// (beets ignores video/data tracks for the same reason).
if isVideoFormat(m.Format) {
continue
}
for _, t := range m.Tracks {
// Same for individual video recordings on audio media.
if t.Recording.IsVideo {
continue
}
// Use the recording MBID, not the track MBID. Tracks
// and recordings have distinct MBIDs in MusicBrainz:
// a track is the placement of a recording on a specific
@@ -565,6 +667,25 @@ func convertRelease(r musicbrainzws2.Release) MBRelease {
return rel
}
// isVideoFormat reports whether a medium's format string names a
// video carrier. "DVD-Audio" stays audio; bare "DVD", "DVD-Video",
// "Blu-ray", "HD-DVD", "VHS", "VCD"/"SVCD" are video.
func isVideoFormat(format string) bool {
f := strings.ToLower(format)
if strings.Contains(f, "dvd-audio") {
return false
}
for _, v := range []string{"dvd", "blu-ray", "bluray", "hd-dvd", "vhs", "vcd"} {
if strings.Contains(f, v) {
return true
}
}
return false
}
func convertReleases(releases []musicbrainzws2.Release) []MBRelease {
out := make([]MBRelease, len(releases))
for i, r := range releases {