Plans 013 and 014, the album page that prompted them, and the smaller fixes they turned up. Changelog, largest first. ## The local library is shaped like files, not like MusicBrainz `audio_files` carries its own tags and points at `albums` and `artists`; `file_genres` is the one real many-to-many. `recordings`, `release_group_recordings`, `artist_credit`, `artist_credit_artist`, `recording_genres`, `release_groups` and `release_to_rg` are gone from the local side, and with them a six-way join in every read, a `MIN(release_group_id)` subquery in eleven queries and a first-credited-artist subquery in nine. Measured on a real 25,966-file library, every many-to-many that model expressed was 1:1 in the data. - Ownership is a file. `GetFilePathsByRecordingMBIDs`, `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812 orphaned recordings, 216 release groups and 260 artists that library carried are now structurally impossible. - One projection: every track query selects from the `track_metadata` view, one row type, one mapper. Nine hand-rolled copies had drifted far enough to report different years on different screens. - `library_id = 0` means every library, so each list query exists once instead of scoped and unscoped with a branch at every call site. - No migration chain. `sql/schemas/` is the one description of the shape; `sql/migrations/`, `applyMigrations` and `schema_migrations` are squashed away, along with the drift between them that had sqlc generating against a stale schema. - `database.InsertTestTrack` is the one test seeder; twenty test files had been assembling the old FK chain each in its own order. ## The catalog stores its ids as bytes `explore_index`'s three 36-char MBID columns and its entity-type text are 16 raw bytes and a small integer. The table and its six indexes go 780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh install is ~0.6 GB rather than ~1.0 GB. - `backend/explore/mbid.go` is the only place the encoding is known; everything above it speaks dashed strings. - `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert rather than silently returning no rows, since SQLite does not coerce between TEXT and BLOB. - The importer asks the artifact what encoding it carries and converts on the way in, so the artifact already published keeps working and no format bump is needed. - `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column list, and `TestStoredEncodingRoundTrips` sweeps every read path. ## An album page that says how much of the album is yours - One question, asked once: is there a file. `filePaths` is filled by a single batched lookup when the tracklist settles, and the badge, the Play count, the dimmed rows and every menu item read it — replacing four claims of decreasing confidence that could show a green tick on an album whose every action did nothing. - Play, Play 7 of 12, or no play button at all. - `total_tracks` on `explore_index` (~2 bytes over 400,677 release groups) and on `audio_files` from tags that have always carried it: a complete MBID-matched album now makes no catalog call at all, where it used to spend the most expensive request the app makes. - A merged cluster shows the running order the most releases agree on, and the version list marks the release you own rather than standing a synthetic entry in for it. - `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed one by a 12-second timer. - Rows not in the library are dimmed in place (with `aria-disabled`) instead of the owned ones wearing a green tick and a legend. ## Caches and cover art get ceilings - Only the three tiers of a cover are stored; the full-resolution copy nothing rendered was 1,134 MB of a 1.4 GB covers directory. - One artist portrait is downloaded and the rest are remembered as URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads. - `browsedArtBudget` and `httpCacheBudget` bound what an age cannot: the same install held art for 5,770 artists in a 1,301-artist library. - `OrphanedArtistImagesJob` joined a bare MBID onto a sharded directory, so it deleted the rows that were the only record of the files it left behind. `explore.ArtistImageDir` is that layout's one definition now. ## The autotag queue asks whether there is work `tagging_items` was a row per album folder, not a queue, and no query read the `tag_status` column that held the answer. The four queue queries ask the files, which matters most where it is least visible: `startPrefetch` was scoring every album in a tagged library against MusicBrainz. ## Phantom playlist tracks resolve in place An M3U8 imported before its files leaves phantom rows; they now match by path and fall back to position, keep their place in the playlist when resolved, and pair best-first so two phantoms cannot claim the same file. ## Playing a track plays the list it is in Double-click, and Play on a single row's menu, queue the list as displayed with `startIndex` on that row — the album page and the track list used to queue one track and discard the album around it. A multi-row selection still plays exactly itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
806 lines
22 KiB
Go
806 lines
22 KiB
Go
package explore
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"go.uploadedlobster.com/mbtypes"
|
|
"go.uploadedlobster.com/musicbrainzws2"
|
|
)
|
|
|
|
// How long a MusicBrainz answer is kept.
|
|
//
|
|
// The rule is what the answer is *about*, not how big it is. A search
|
|
// is a ranking and shifts; an entity is a fact about a record that was
|
|
// published years ago and does not. Entity data used to expire after a
|
|
// week, which meant a fully-populated artist page re-fetched itself
|
|
// every week forever - on a real install, 251 of 2,930 cached rows were
|
|
// already expired and waiting to be paid for again. The bytes are
|
|
// already on disk; re-fetching them buys nothing and spends someone
|
|
// else's rate limit.
|
|
const (
|
|
// cacheTTLSearch is the TTL for search results (results may shift).
|
|
cacheTTLSearch = 24 * time.Hour
|
|
// cacheTTLEntity is the TTL for lookup/browse results. An artist's
|
|
// name, country and relations, a release group's title and date:
|
|
// these change on the order of never, and a wrong one is corrected
|
|
// by the next catalog artifact rather than by an expiry.
|
|
cacheTTLEntity = 365 * 24 * time.Hour
|
|
// cacheTTLReleases is the TTL for a release group's releases +
|
|
// tracklists. Effectively immutable once published.
|
|
cacheTTLReleases = 365 * 24 * time.Hour
|
|
)
|
|
|
|
// MusicBrainzClient wraps the musicbrainzws2 library with a local
|
|
// response cache. Every API call checks the cache first and stores
|
|
// successful responses for future hits.
|
|
//
|
|
// A proactive rate limiter gates all outgoing requests at 1 req/sec
|
|
// to avoid triggering MusicBrainz 429 responses. The underlying
|
|
// musicbrainzws2.Client still retries on 429 as a safety net, but
|
|
// the limiter should prevent most rate-limit hits.
|
|
type MusicBrainzClient struct {
|
|
mb *musicbrainzws2.Client
|
|
cache *Cache
|
|
limiter *RateLimiter
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewMusicBrainzClient creates a MusicBrainz API client that caches
|
|
// responses in the given Cache. The provided rate limiter is shared
|
|
// with all other MB consumers (e.g. artist image resolution) to
|
|
// prevent concurrent bursts from triggering 429s.
|
|
func NewMusicBrainzClient(
|
|
cache *Cache,
|
|
limiter *RateLimiter,
|
|
logger *slog.Logger,
|
|
) *MusicBrainzClient {
|
|
mb := musicbrainzws2.NewClient(musicbrainzws2.AppInfo{
|
|
Name: "YellowJacket",
|
|
Version: "dev",
|
|
URL: "https://github.com/yellowjacket",
|
|
})
|
|
|
|
return &MusicBrainzClient{
|
|
mb: mb,
|
|
cache: cache,
|
|
limiter: limiter,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// Close releases resources held by the underlying HTTP client.
|
|
func (c *MusicBrainzClient) Close() error {
|
|
return c.mb.Close()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Search
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// SearchArtists queries MusicBrainz for artists matching the given
|
|
// query string. Returns results, the total match count from MB,
|
|
// and any error. Results are cached for 1 day.
|
|
func (c *MusicBrainzClient) SearchArtists(
|
|
ctx context.Context, query string, limit int,
|
|
) ([]MBArtist, int, error) {
|
|
cacheKey := fmt.Sprintf("mb:search:artist:%s:%d", query, limit)
|
|
|
|
if data, ok := c.cache.Get(cacheKey); ok {
|
|
var cached mbSearchCache[MBArtist]
|
|
if err := json.Unmarshal(data, &cached); err == nil {
|
|
return cached.Results, cached.TotalCount, nil
|
|
}
|
|
}
|
|
|
|
if err := c.limiter.Wait(ctx); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
c.logger.Info("musicbrainz search artists",
|
|
"query", query,
|
|
"limit", limit,
|
|
)
|
|
|
|
result, err := c.mb.SearchArtists(ctx,
|
|
musicbrainzws2.SearchFilter{Query: query},
|
|
musicbrainzws2.Paginator{Limit: clampLimit(limit)},
|
|
)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
out := convertArtists(result.Artists)
|
|
|
|
c.cacheJSON(cacheKey, mbSearchCache[MBArtist]{
|
|
Results: out, TotalCount: result.Count,
|
|
}, cacheTTLSearch, "", "")
|
|
|
|
return out, result.Count, nil
|
|
}
|
|
|
|
// SearchReleaseGroups queries MusicBrainz for release groups
|
|
// matching the given query string.
|
|
func (c *MusicBrainzClient) SearchReleaseGroups(
|
|
ctx context.Context, query string, limit int,
|
|
) ([]MBReleaseGroup, int, error) {
|
|
cacheKey := fmt.Sprintf("mb:search:release-group:%s:%d", query, limit)
|
|
|
|
if data, ok := c.cache.Get(cacheKey); ok {
|
|
var cached mbSearchCache[MBReleaseGroup]
|
|
if err := json.Unmarshal(data, &cached); err == nil {
|
|
return cached.Results, cached.TotalCount, nil
|
|
}
|
|
}
|
|
|
|
if err := c.limiter.Wait(ctx); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
c.logger.Info("musicbrainz search release groups",
|
|
"query", query,
|
|
"limit", limit,
|
|
)
|
|
|
|
result, err := c.mb.SearchReleaseGroups(ctx,
|
|
musicbrainzws2.SearchFilter{Query: query},
|
|
musicbrainzws2.Paginator{Limit: clampLimit(limit)},
|
|
)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
out := convertReleaseGroups(result.ReleaseGroups)
|
|
|
|
c.cacheJSON(cacheKey, mbSearchCache[MBReleaseGroup]{
|
|
Results: out, TotalCount: result.Count,
|
|
}, cacheTTLSearch, "", "")
|
|
|
|
return out, result.Count, nil
|
|
}
|
|
|
|
// SearchRecordings queries MusicBrainz for recordings matching the
|
|
// given query string.
|
|
func (c *MusicBrainzClient) SearchRecordings(
|
|
ctx context.Context, query string, limit int,
|
|
) ([]MBRecording, int, error) {
|
|
cacheKey := fmt.Sprintf("mb:search:recording:%s:%d", query, limit)
|
|
|
|
if data, ok := c.cache.Get(cacheKey); ok {
|
|
var cached mbSearchCache[MBRecording]
|
|
if err := json.Unmarshal(data, &cached); err == nil {
|
|
return cached.Results, cached.TotalCount, nil
|
|
}
|
|
}
|
|
|
|
if err := c.limiter.Wait(ctx); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
c.logger.Info("musicbrainz search recordings",
|
|
"query", query,
|
|
"limit", limit,
|
|
)
|
|
|
|
result, err := c.mb.SearchRecordings(ctx,
|
|
musicbrainzws2.SearchFilter{Query: query},
|
|
musicbrainzws2.Paginator{Limit: clampLimit(limit)},
|
|
)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
out := convertRecordings(result.Recordings)
|
|
|
|
c.cacheJSON(cacheKey, mbSearchCache[MBRecording]{
|
|
Results: out, TotalCount: result.Count,
|
|
}, cacheTTLSearch, "", "")
|
|
|
|
return out, result.Count, nil
|
|
}
|
|
|
|
// mbSearchCache wraps search results with the total count for caching.
|
|
type mbSearchCache[T any] struct {
|
|
Results []T `json:"results"`
|
|
TotalCount int `json:"totalCount"`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lookup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// LookupArtist fetches a single artist by MBID. Cached for 7 days.
|
|
// Uses inc=release-groups to pre-populate the browse cache so the
|
|
// subsequent BrowseReleaseGroups call is a free cache hit.
|
|
func (c *MusicBrainzClient) LookupArtist(
|
|
ctx context.Context, mbid string,
|
|
) (*MBArtist, error) {
|
|
cacheKey := "mb:lookup:artist:" + mbid
|
|
|
|
if data, ok := c.cache.Get(cacheKey); ok {
|
|
var out MBArtist
|
|
if err := json.Unmarshal(data, &out); err == nil {
|
|
return &out, nil
|
|
}
|
|
}
|
|
|
|
if err := c.limiter.Wait(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c.logger.Info("musicbrainz lookup artist", "mbid", mbid)
|
|
|
|
a, err := c.mb.LookupArtist(ctx,
|
|
mbtypes.MBID(mbid),
|
|
musicbrainzws2.IncludesFilter{Includes: []string{"release-groups"}},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := convertArtist(a)
|
|
|
|
c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "artist")
|
|
|
|
// Pre-populate the browse cache with the included release groups
|
|
// so BrowseReleaseGroups returns instantly from cache.
|
|
// The inc= response is limited to 25 items; only cache if we
|
|
// likely got the full discography (< 25 means no truncation).
|
|
if len(a.ReleaseGroups) > 0 && len(a.ReleaseGroups) < 25 {
|
|
browseKey := "mb:browse:release-groups:" + mbid
|
|
rgs := convertReleaseGroups(a.ReleaseGroups)
|
|
c.cacheJSON(browseKey, rgs, cacheTTLEntity, mbid, "artist")
|
|
}
|
|
|
|
return &out, nil
|
|
}
|
|
|
|
// LookupReleaseGroup fetches a single release group by MBID.
|
|
func (c *MusicBrainzClient) LookupReleaseGroup(
|
|
ctx context.Context, mbid string,
|
|
) (*MBReleaseGroup, error) {
|
|
cacheKey := "mb:lookup:release-group:" + mbid
|
|
|
|
if data, ok := c.cache.Get(cacheKey); ok {
|
|
var out MBReleaseGroup
|
|
if err := json.Unmarshal(data, &out); err == nil {
|
|
return &out, nil
|
|
}
|
|
}
|
|
|
|
if err := c.limiter.Wait(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c.logger.Info("musicbrainz lookup release group", "mbid", mbid)
|
|
|
|
rg, err := c.mb.LookupReleaseGroup(ctx,
|
|
mbtypes.MBID(mbid),
|
|
musicbrainzws2.IncludesFilter{Includes: []string{"artist-credits"}},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := convertReleaseGroup(rg)
|
|
|
|
c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "release-group")
|
|
|
|
return &out, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Browse
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// BrowseReleaseGroups fetches the release groups for a given artist
|
|
// MBID. Cached for 7 days.
|
|
func (c *MusicBrainzClient) BrowseReleaseGroups(
|
|
ctx context.Context, artistMBID string,
|
|
) ([]MBReleaseGroup, error) {
|
|
cacheKey := "mb:browse:release-groups:" + artistMBID
|
|
|
|
if data, ok := c.cache.Get(cacheKey); ok {
|
|
var out []MBReleaseGroup
|
|
if err := json.Unmarshal(data, &out); err == nil {
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
if err := c.limiter.Wait(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c.logger.Info("musicbrainz browse release groups",
|
|
"artistMBID", artistMBID,
|
|
)
|
|
|
|
result, err := c.mb.BrowseReleaseGroups(ctx,
|
|
musicbrainzws2.ReleaseGroupFilter{
|
|
ArtistMBID: mbtypes.MBID(artistMBID),
|
|
},
|
|
musicbrainzws2.Paginator{Limit: musicbrainzws2.MaxLimit},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := convertReleaseGroups(result.ReleaseGroups)
|
|
|
|
c.cacheJSON(cacheKey, out, cacheTTLEntity, artistMBID, "artist")
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// browseMaxPages bounds BrowseReleaseGroupsAll. At MaxLimit per page
|
|
// that is 1 000 release groups, which no real artist reaches — it is a
|
|
// runaway guard for a server that stops honouring the offset, not a
|
|
// coverage decision.
|
|
const browseMaxPages = 10
|
|
|
|
// BrowseReleaseGroupsAll is BrowseReleaseGroups paged to exhaustion.
|
|
//
|
|
// The single-page call above asks for MaxLimit (100) and takes whatever
|
|
// comes back, which silently truncates a prolific artist's discography
|
|
// at 100 release groups — invisible unless you count, since a hundred
|
|
// albums looks like a complete page. This is the call to use when the
|
|
// answer is meant to be the whole discography rather than a page of it.
|
|
//
|
|
// The result is cached under the same key the single-page call reads,
|
|
// so a later interactive browse is served the complete list.
|
|
func (c *MusicBrainzClient) BrowseReleaseGroupsAll(
|
|
ctx context.Context, artistMBID string,
|
|
) ([]MBReleaseGroup, error) {
|
|
cacheKey := "mb:browse:release-groups:" + artistMBID
|
|
|
|
if data, ok := c.cache.Get(cacheKey); ok {
|
|
var out []MBReleaseGroup
|
|
if err := json.Unmarshal(data, &out); err == nil {
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
var out []MBReleaseGroup
|
|
|
|
for page := range browseMaxPages {
|
|
if err := c.limiter.Wait(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
offset := page * musicbrainzws2.MaxLimit
|
|
|
|
c.logger.Info("musicbrainz browse release groups",
|
|
"artistMBID", artistMBID,
|
|
"offset", offset,
|
|
)
|
|
|
|
result, err := c.mb.BrowseReleaseGroups(ctx,
|
|
musicbrainzws2.ReleaseGroupFilter{
|
|
ArtistMBID: mbtypes.MBID(artistMBID),
|
|
},
|
|
musicbrainzws2.Paginator{
|
|
Limit: musicbrainzws2.MaxLimit,
|
|
Offset: offset,
|
|
},
|
|
)
|
|
if err != nil {
|
|
// Pages already fetched are still worth keeping if there are
|
|
// any: a partial discography beats none, and the caller's
|
|
// mark is only set on a nil error, so the rest is retried.
|
|
if len(out) > 0 {
|
|
return out, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
out = append(out, convertReleaseGroups(result.ReleaseGroups)...)
|
|
|
|
// A short page is the last page. MB reports the full count too,
|
|
// but a short page is the condition that terminates correctly
|
|
// even when the count and the pages disagree.
|
|
if len(result.ReleaseGroups) < musicbrainzws2.MaxLimit {
|
|
break
|
|
}
|
|
}
|
|
|
|
c.cacheJSON(cacheKey, out, cacheTTLEntity, artistMBID, "artist")
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// LookupRelease fetches a single release by MBID (with media +
|
|
// recordings). Used by the autotag paste-URL escape hatch.
|
|
// Cached for 7 days.
|
|
func (c *MusicBrainzClient) LookupRelease(
|
|
ctx context.Context, mbid string,
|
|
) (*MBRelease, error) {
|
|
cacheKey := "mb:lookup:release:" + mbid
|
|
|
|
if data, ok := c.cache.Get(cacheKey); ok {
|
|
var out MBRelease
|
|
if err := json.Unmarshal(data, &out); err == nil {
|
|
return &out, nil
|
|
}
|
|
}
|
|
|
|
if err := c.limiter.Wait(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c.logger.Info("musicbrainz lookup release", "mbid", mbid)
|
|
|
|
r, err := c.mb.LookupRelease(
|
|
ctx,
|
|
mbtypes.MBID(mbid),
|
|
musicbrainzws2.IncludesFilter{
|
|
Includes: []string{"recordings", "media", "artist-credits", "release-groups"},
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := convertRelease(r)
|
|
|
|
c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "release")
|
|
|
|
return &out, nil
|
|
}
|
|
|
|
// 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 []MBRecordingRelease
|
|
if err := json.Unmarshal(data, &out); err == nil {
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
if err := c.limiter.Wait(ctx); err != nil {
|
|
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,
|
|
)
|
|
|
|
result, err := c.mb.BrowseReleases(ctx,
|
|
musicbrainzws2.ReleaseFilter{
|
|
ReleaseGroupMBID: mbtypes.MBID(releaseGroupMBID),
|
|
Includes: []string{"recordings", "media"},
|
|
},
|
|
musicbrainzws2.Paginator{Limit: musicbrainzws2.MaxLimit},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := convertReleases(result.Releases)
|
|
|
|
c.cacheJSON(cacheKey, out, cacheTTLReleases, releaseGroupMBID, "release-group")
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// cacheJSON marshals v to JSON and stores it in the cache.
|
|
func (c *MusicBrainzClient) cacheJSON(
|
|
key string,
|
|
v any,
|
|
ttl time.Duration,
|
|
mbid string,
|
|
entityType string,
|
|
) {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
c.logger.Warn("musicbrainz cache marshal error",
|
|
"key", key,
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
c.cache.Set(key, data, ttl, mbid, entityType)
|
|
}
|
|
|
|
// clampLimit restricts the search limit to the MusicBrainz maximum.
|
|
func clampLimit(limit int) int {
|
|
if limit <= 0 || limit > musicbrainzws2.MaxLimit {
|
|
return musicbrainzws2.DefaultLimit
|
|
}
|
|
|
|
return limit
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Type converters (musicbrainzws2 → Wails wrapper types)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func convertArtist(a musicbrainzws2.Artist) MBArtist {
|
|
out := MBArtist{
|
|
MBID: string(a.ID),
|
|
Name: a.Name,
|
|
SortName: a.SortName,
|
|
Type: a.Type,
|
|
Country: string(a.CountryCode),
|
|
Disambiguation: a.Disambiguation,
|
|
Score: a.Score,
|
|
OriginalScore: a.Score,
|
|
}
|
|
|
|
// Extract the primary English alias when the canonical name
|
|
// is non-Latin (CJK, Cyrillic, etc.). This lets the frontend
|
|
// show "Tatsuro Yamashita" alongside "山下達郎".
|
|
if !isLatinScript(a.Name) {
|
|
out.EnglishName = primaryEnglishAlias(a.Aliases)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func convertArtists(artists []musicbrainzws2.Artist) []MBArtist {
|
|
out := make([]MBArtist, len(artists))
|
|
for i, a := range artists {
|
|
out[i] = convertArtist(a)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// primaryEnglishAlias returns the primary English alias name from
|
|
// a slice of aliases, or "" if none exists.
|
|
func primaryEnglishAlias(aliases []musicbrainzws2.Alias) string {
|
|
// Prefer primary English alias.
|
|
for _, a := range aliases {
|
|
if a.Locale == "en" && a.IsPrimary {
|
|
return a.Name
|
|
}
|
|
}
|
|
|
|
// Fall back to any English alias.
|
|
for _, a := range aliases {
|
|
if a.Locale == "en" {
|
|
return a.Name
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// isLatinScript returns true if the string consists primarily of
|
|
// Latin characters, digits, and common punctuation. Returns false
|
|
// for CJK, Cyrillic, Arabic, etc.
|
|
func isLatinScript(s string) bool {
|
|
for _, r := range s {
|
|
if unicode.IsLetter(r) && !unicode.In(r, unicode.Latin) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func convertReleaseGroup(rg musicbrainzws2.ReleaseGroup) MBReleaseGroup {
|
|
return MBReleaseGroup{
|
|
MBID: string(rg.ID),
|
|
Title: rg.Title,
|
|
PrimaryType: rg.PrimaryType,
|
|
SecondaryTypes: rg.SecondaryTypes,
|
|
FirstReleaseDate: rg.FirstReleaseDate.String(),
|
|
ArtistCredit: rg.ArtistCredit.String(),
|
|
Score: rg.Score,
|
|
}
|
|
}
|
|
|
|
func convertReleaseGroups(rgs []musicbrainzws2.ReleaseGroup) []MBReleaseGroup {
|
|
out := make([]MBReleaseGroup, len(rgs))
|
|
for i, rg := range rgs {
|
|
out[i] = convertReleaseGroup(rg)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func convertRelease(r musicbrainzws2.Release) MBRelease {
|
|
rel := MBRelease{
|
|
MBID: string(r.ID),
|
|
Title: r.Title,
|
|
Date: r.Date.String(),
|
|
Country: string(r.CountryCode),
|
|
Status: r.Status,
|
|
ArtistCredit: r.ArtistCredit.String(),
|
|
}
|
|
|
|
if r.ReleaseGroup != nil {
|
|
rel.ReleaseGroupMBID = string(r.ReleaseGroup.ID)
|
|
}
|
|
|
|
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
|
|
// medium/release, while a recording is the underlying
|
|
// audio work. Library-tagged audio files store the
|
|
// recording MBID (MusicBrainz Track Id is a misnomer),
|
|
// so that's what the local recordings.mbid column
|
|
// contains — and that's what we need to match against
|
|
// for the library-status indicator to be accurate.
|
|
recordingMBID := string(t.Recording.ID)
|
|
if recordingMBID == "" {
|
|
// Fall back to the track MBID if the API response
|
|
// didn't include the recording relation (older
|
|
// browse endpoints). Better than empty.
|
|
recordingMBID = string(t.ID)
|
|
}
|
|
|
|
rel.Tracks = append(rel.Tracks, MBTrack{
|
|
Position: t.Position,
|
|
DiscNumber: m.Position,
|
|
Title: t.Title,
|
|
Length: int(t.Length.Milliseconds()),
|
|
MBID: recordingMBID,
|
|
})
|
|
}
|
|
}
|
|
|
|
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 {
|
|
out[i] = convertRelease(r)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func convertRecording(r musicbrainzws2.Recording) MBRecording {
|
|
return MBRecording{
|
|
MBID: string(r.ID),
|
|
Title: r.Title,
|
|
Length: int(r.Length.Milliseconds()),
|
|
ArtistCredit: r.ArtistCredit.String(),
|
|
Score: r.Score,
|
|
}
|
|
}
|
|
|
|
func convertRecordings(recordings []musicbrainzws2.Recording) []MBRecording {
|
|
out := make([]MBRecording, len(recordings))
|
|
for i, r := range recordings {
|
|
out[i] = convertRecording(r)
|
|
}
|
|
|
|
return out
|
|
}
|