better release search. artist search just ok

This commit is contained in:
Gabe Farrell 2026-04-09 19:57:47 -04:00
parent 4324de1271
commit eca7f4ba31
8 changed files with 121 additions and 38 deletions

View file

@ -21,10 +21,16 @@ type mbArtistCredit struct {
} `json:"artist"`
}
type mbMedia struct {
Format string `json:"format"`
}
type mbRelease struct {
ID string `json:"id"`
Title string `json:"title"`
Date string `json:"date"`
Country string `json:"country"`
Media []mbMedia `json:"media"`
ArtistCredit []mbArtistCredit `json:"artist-credit"`
ReleaseGroup struct {
PrimaryType string `json:"primary-type"`
@ -68,7 +74,7 @@ func searchMBReleases(query string) ([]mbRelease, error) {
var result struct {
Releases []mbRelease `json:"releases"`
}
err := mbGet("/ws/2/release/?query="+url.QueryEscape(query)+"&fmt=json&limit=20", &result)
err := mbGet("/ws/2/release/?query="+url.QueryEscape(query)+"&fmt=json&limit=20&inc=media", &result)
return result.Releases, err
}
@ -80,21 +86,64 @@ func searchMBArtists(query string) ([]mbArtist, error) {
return result.Artists, err
}
// getFirstReleaseMBID returns the MBID of the first release listed under a
// release group. This is needed because beets --search-id requires a release
// MBID, not a release group MBID.
// Returns empty string on error so callers can fall back gracefully.
func getFirstReleaseMBID(rgMBID string) string {
// releaseFormatScore returns a preference score for a release's media format.
// Higher is better. CD=2, Digital Media=1, anything else=0.
func releaseFormatScore(r mbRelease) int {
for _, m := range r.Media {
switch m.Format {
case "CD":
return 2
case "Digital Media":
return 1
}
}
return 0
}
// releaseCountryScore returns a preference score for a release's country.
// Higher is better. KR=3, JP=2, XW=1, anything else=0.
func releaseCountryScore(r mbRelease) int {
switch r.Country {
case "KR":
return 3
case "JP":
return 2
case "XW":
return 1
}
return 0
}
// pickBestRelease selects the preferred release from a list.
// Format (CD > Digital Media > *) is the primary sort key;
// country (KR > JP > XW > *) breaks ties.
func pickBestRelease(releases []mbRelease) *mbRelease {
if len(releases) == 0 {
return nil
}
best := &releases[0]
for i := 1; i < len(releases); i++ {
r := &releases[i]
rf, bf := releaseFormatScore(*r), releaseFormatScore(*best)
if rf > bf || (rf == bf && releaseCountryScore(*r) > releaseCountryScore(*best)) {
best = r
}
}
return best
}
// pickBestReleaseForGroup fetches all releases for a release group via the
// MusicBrainz browse API (with media info) and returns the preferred release.
// Returns nil on error or when the group has no releases.
func pickBestReleaseForGroup(rgMBID string) *mbRelease {
var result struct {
Releases []struct {
ID string `json:"id"`
} `json:"releases"`
Releases []mbRelease `json:"releases"`
}
path := fmt.Sprintf("/ws/2/release-group/%s?fmt=json&inc=releases", url.QueryEscape(rgMBID))
path := fmt.Sprintf("/ws/2/release?release-group=%s&fmt=json&inc=media&limit=100", url.QueryEscape(rgMBID))
if err := mbGet(path, &result); err != nil || len(result.Releases) == 0 {
return ""
return nil
}
return result.Releases[0].ID
return pickBestRelease(result.Releases)
}
// getMBArtistReleaseGroups returns all Album and EP release groups for an artist,
@ -153,12 +202,20 @@ func fetchArtist(artistMBID, artistName string, logf func(string)) error {
failed := 0
for i, rg := range groups {
logf(fmt.Sprintf("[%d/%d] %s", i+1, len(groups), rg.Title))
// Resolve a release MBID for this release group. beets --search-id
// requires a release MBID; release group MBIDs are not accepted.
// Pick the best release for this group. beets --search-id requires a
// release MBID; release group MBIDs are not accepted.
time.Sleep(time.Second) // MusicBrainz rate limit
releaseMBID := getFirstReleaseMBID(rg.ID)
if releaseMBID == "" {
logf(fmt.Sprintf(" ↳ warning: could not resolve release MBID for group %s, beets will search by name", rg.ID))
rel := pickBestReleaseForGroup(rg.ID)
releaseMBID := ""
if rel == nil {
logf(fmt.Sprintf(" ↳ warning: could not resolve release for group %s, beets will search by name", rg.ID))
} else {
releaseMBID = rel.ID
format := ""
if len(rel.Media) > 0 {
format = rel.Media[0].Format
}
logf(fmt.Sprintf(" ↳ selected release: %s [%s / %s]", releaseMBID, format, rel.Country))
}
folder, err := fetchRelease(artistName, rg.Title, releaseMBID, logf)

View file

@ -213,7 +213,7 @@ func RunImporter() {
fmt.Println("→ Downloading cover art for album:", albumPath)
if _, err := FindCoverImage(albumPath); err != nil {
if err := DownloadCoverArt(albumPath, md); err != nil {
if err := DownloadCoverArt(albumPath, md, ""); err != nil {
fmt.Println("Cover art download failed:", err)
}
}

View file

@ -57,15 +57,18 @@ func EmbedAlbumArtIntoFolder(albumDir string) error {
return err
}
// DownloadCoverArt searches MusicBrainz for a release matching md's artist and
// album, then downloads the front cover from the Cover Art Archive and saves it
// as cover.jpg inside albumDir. Returns an error if no cover could be found or
// downloaded.
func DownloadCoverArt(albumDir string, md *MusicMetadata) error {
mbid, err := searchMusicBrainzRelease(md.Artist, md.Album)
// DownloadCoverArt downloads the front cover from the Cover Art Archive and
// saves it as cover.jpg/cover.png inside albumDir.
// If mbid is non-empty it is used directly, bypassing the MusicBrainz search.
// Otherwise, a search is performed using md's artist and album.
func DownloadCoverArt(albumDir string, md *MusicMetadata, mbid string) error {
if mbid == "" {
var err error
mbid, err = searchMusicBrainzRelease(md.Artist, md.Album)
if err != nil {
return fmt.Errorf("MusicBrainz release search failed: %w", err)
}
}
data, ext, err := fetchCoverArtArchiveFront(mbid)
if err != nil {

View file

@ -207,7 +207,9 @@ func snapMP3Bitrate(bpsStr string) int {
// (which exit 0 but produce a "skip" log entry) are detected and
// returned as errors, triggering the MusicBrainz fallback.
// If mbid is non-empty it is passed as --search-id to pin beets to a specific
// MusicBrainz release.
// MusicBrainz release. In that case, quiet mode is skipped and newlines are
// piped to stdin so beets auto-accepts the pinned release regardless of
// confidence score.
func tagWithBeets(path, mbid string) error {
fmt.Println("→ Tagging with beets:", path)
@ -219,15 +221,24 @@ func tagWithBeets(path, mbid string) error {
logFile.Close()
defer os.Remove(logPath)
args := []string{"import", "-Cq", "-l", logPath}
// passing mbid to beet removed temporarily
// if mbid != "" {
// args = append(args, "--search-id", mbid)
// }
args = append(args, path)
args := []string{"import", "-C", "-l", logPath}
if mbid != "" {
// Drop -q so beets doesn't skip on low confidence. Pipe newlines to
// auto-accept the interactive prompt for the MBID-pinned release.
args = append(args, "--search-id", mbid, path)
cmd := exec.Command("beet", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = strings.NewReader(strings.Repeat("A\n", 20))
if err := cmd.Run(); err != nil {
return err
}
} else {
args = append(args, "-q", path)
if err := runCmd("beet", args...); err != nil {
return err
}
}
// Even on exit 0, beets may have skipped the album in quiet mode.
// The log format is one entry per line: "<action> <path>"

View file

@ -237,7 +237,7 @@ func importPendingRelease(pd *pendingDownload, localDir string) {
logf("ReplayGain applied")
if _, err := FindCoverImage(localDir); err != nil {
if err := DownloadCoverArt(localDir, md); err != nil {
if err := DownloadCoverArt(localDir, md, pd.BeetsMBID); err != nil {
logf(fmt.Sprintf("Cover art download warning: %v", err))
}
}

View file

@ -141,7 +141,7 @@ func slskdSearchIsTerminal(state string) bool {
// then returns the responses from the dedicated /responses sub-endpoint.
// Each poll check-in is reported via logf.
func pollSlskdSearch(id string, logf func(string)) ([]slskdPeerResponse, error) {
deadline := time.Now().Add(30 * time.Second)
deadline := time.Now().Add(60 * time.Second)
for {
resp, err := slskdDo("GET", "/api/v0/searches/"+id, nil)
if err != nil {

View file

@ -100,10 +100,14 @@ function renderRelease(r) {
const artist = credits.map(c => c.name || c.artist?.name || '').join('') || 'Unknown Artist';
const year = r.date?.substring(0, 4) ?? '';
const type = r['release-group']?.['primary-type'] ?? '';
const meta = [year, type].filter(Boolean).join(' \u00b7 ');
const country = r.country ?? '';
const formats = [...new Set((r.media ?? []).map(m => m.format).filter(Boolean))].join('+');
const meta = [year, type, formats, country].filter(Boolean).join(' \u00b7 ');
const coverUrl = `https://coverartarchive.org/release/${r.id}/front-250`;
return `
<div class="result-row">
<img class="result-cover" src="${coverUrl}" onerror="this.style.display='none'" loading="lazy" alt="">
<div class="result-info">
<span class="result-title">${esc(artist)} \u2014 ${esc(r.title)}</span>
${meta ? `<span class="result-meta">${esc(meta)}</span>` : ''}

View file

@ -332,6 +332,14 @@ h1 {
padding: 12px 16px;
margin-bottom: 8px;
}
.result-cover {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: var(--radius-sm);
flex-shrink: 0;
background: var(--surface-hi);
}
.result-info { flex: 1; min-width: 0; }
.result-title {
display: block;