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"` } `json:"artist"`
} }
type mbMedia struct {
Format string `json:"format"`
}
type mbRelease struct { type mbRelease struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Date string `json:"date"` Date string `json:"date"`
Country string `json:"country"`
Media []mbMedia `json:"media"`
ArtistCredit []mbArtistCredit `json:"artist-credit"` ArtistCredit []mbArtistCredit `json:"artist-credit"`
ReleaseGroup struct { ReleaseGroup struct {
PrimaryType string `json:"primary-type"` PrimaryType string `json:"primary-type"`
@ -68,7 +74,7 @@ func searchMBReleases(query string) ([]mbRelease, error) {
var result struct { var result struct {
Releases []mbRelease `json:"releases"` 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 return result.Releases, err
} }
@ -80,21 +86,64 @@ func searchMBArtists(query string) ([]mbArtist, error) {
return result.Artists, err return result.Artists, err
} }
// getFirstReleaseMBID returns the MBID of the first release listed under a // releaseFormatScore returns a preference score for a release's media format.
// release group. This is needed because beets --search-id requires a release // Higher is better. CD=2, Digital Media=1, anything else=0.
// MBID, not a release group MBID. func releaseFormatScore(r mbRelease) int {
// Returns empty string on error so callers can fall back gracefully. for _, m := range r.Media {
func getFirstReleaseMBID(rgMBID string) string { 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 { var result struct {
Releases []struct { Releases []mbRelease `json:"releases"`
ID string `json:"id"`
} `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 { 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, // 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 failed := 0
for i, rg := range groups { for i, rg := range groups {
logf(fmt.Sprintf("[%d/%d] %s", i+1, len(groups), rg.Title)) logf(fmt.Sprintf("[%d/%d] %s", i+1, len(groups), rg.Title))
// Resolve a release MBID for this release group. beets --search-id // Pick the best release for this group. beets --search-id requires a
// requires a release MBID; release group MBIDs are not accepted. // release MBID; release group MBIDs are not accepted.
time.Sleep(time.Second) // MusicBrainz rate limit time.Sleep(time.Second) // MusicBrainz rate limit
releaseMBID := getFirstReleaseMBID(rg.ID) rel := pickBestReleaseForGroup(rg.ID)
if releaseMBID == "" { releaseMBID := ""
logf(fmt.Sprintf(" ↳ warning: could not resolve release MBID for group %s, beets will search by name", rg.ID)) 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) folder, err := fetchRelease(artistName, rg.Title, releaseMBID, logf)

View file

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

View file

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

View file

@ -207,7 +207,9 @@ func snapMP3Bitrate(bpsStr string) int {
// (which exit 0 but produce a "skip" log entry) are detected and // (which exit 0 but produce a "skip" log entry) are detected and
// returned as errors, triggering the MusicBrainz fallback. // returned as errors, triggering the MusicBrainz fallback.
// If mbid is non-empty it is passed as --search-id to pin beets to a specific // 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 { func tagWithBeets(path, mbid string) error {
fmt.Println("→ Tagging with beets:", path) fmt.Println("→ Tagging with beets:", path)
@ -219,14 +221,23 @@ func tagWithBeets(path, mbid string) error {
logFile.Close() logFile.Close()
defer os.Remove(logPath) defer os.Remove(logPath)
args := []string{"import", "-Cq", "-l", logPath} args := []string{"import", "-C", "-l", logPath}
// passing mbid to beet removed temporarily if mbid != "" {
// if mbid != "" { // Drop -q so beets doesn't skip on low confidence. Pipe newlines to
// args = append(args, "--search-id", mbid) // auto-accept the interactive prompt for the MBID-pinned release.
// } args = append(args, "--search-id", mbid, path)
args = append(args, path) cmd := exec.Command("beet", args...)
if err := runCmd("beet", args...); err != nil { cmd.Stdout = os.Stdout
return err 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. // Even on exit 0, beets may have skipped the album in quiet mode.

View file

@ -237,7 +237,7 @@ func importPendingRelease(pd *pendingDownload, localDir string) {
logf("ReplayGain applied") logf("ReplayGain applied")
if _, err := FindCoverImage(localDir); err != nil { 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)) 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. // then returns the responses from the dedicated /responses sub-endpoint.
// Each poll check-in is reported via logf. // Each poll check-in is reported via logf.
func pollSlskdSearch(id string, logf func(string)) ([]slskdPeerResponse, error) { func pollSlskdSearch(id string, logf func(string)) ([]slskdPeerResponse, error) {
deadline := time.Now().Add(30 * time.Second) deadline := time.Now().Add(60 * time.Second)
for { for {
resp, err := slskdDo("GET", "/api/v0/searches/"+id, nil) resp, err := slskdDo("GET", "/api/v0/searches/"+id, nil)
if err != 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 artist = credits.map(c => c.name || c.artist?.name || '').join('') || 'Unknown Artist';
const year = r.date?.substring(0, 4) ?? ''; const year = r.date?.substring(0, 4) ?? '';
const type = r['release-group']?.['primary-type'] ?? ''; 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 ` return `
<div class="result-row"> <div class="result-row">
<img class="result-cover" src="${coverUrl}" onerror="this.style.display='none'" loading="lazy" alt="">
<div class="result-info"> <div class="result-info">
<span class="result-title">${esc(artist)} \u2014 ${esc(r.title)}</span> <span class="result-title">${esc(artist)} \u2014 ${esc(r.title)}</span>
${meta ? `<span class="result-meta">${esc(meta)}</span>` : ''} ${meta ? `<span class="result-meta">${esc(meta)}</span>` : ''}

View file

@ -332,6 +332,14 @@ h1 {
padding: 12px 16px; padding: 12px 16px;
margin-bottom: 8px; 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-info { flex: 1; min-width: 0; }
.result-title { .result-title {
display: block; display: block;