From eca7f4ba31debba9dea09bf8206dd20d924bb154 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Thu, 9 Apr 2026 19:57:47 -0400 Subject: [PATCH 1/5] better release search. artist search just ok --- discover.go | 91 +++++++++++++++++++++++++++++++++++++++--------- importer.go | 2 +- media.go | 19 +++++----- metadata.go | 29 ++++++++++----- monitor.go | 2 +- slskd.go | 2 +- static/app.js | 6 +++- static/style.css | 8 +++++ 8 files changed, 121 insertions(+), 38 deletions(-) diff --git a/discover.go b/discover.go index b24f020..9fe5b45 100644 --- a/discover.go +++ b/discover.go @@ -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) diff --git a/importer.go b/importer.go index a7d5c5c..9252ee2 100644 --- a/importer.go +++ b/importer.go @@ -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) } } diff --git a/media.go b/media.go index 555b8e7..232f845 100644 --- a/media.go +++ b/media.go @@ -57,14 +57,17 @@ 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) - if err != nil { - return fmt.Errorf("MusicBrainz release search failed: %w", err) +// 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) diff --git a/metadata.go b/metadata.go index 19f622f..da146a8 100644 --- a/metadata.go +++ b/metadata.go @@ -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,14 +221,23 @@ 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) - if err := runCmd("beet", args...); err != nil { - return err + 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. diff --git a/monitor.go b/monitor.go index 77f06b7..68dc608 100644 --- a/monitor.go +++ b/monitor.go @@ -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)) } } diff --git a/slskd.go b/slskd.go index 35e9c1c..286f1e6 100644 --- a/slskd.go +++ b/slskd.go @@ -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 { diff --git a/static/app.js b/static/app.js index 64d822c..c06e801 100644 --- a/static/app.js +++ b/static/app.js @@ -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 `
+
${esc(artist)} \u2014 ${esc(r.title)} ${meta ? `${esc(meta)}` : ''} diff --git a/static/style.css b/static/style.css index 36713c9..2a5cb33 100644 --- a/static/style.css +++ b/static/style.css @@ -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; From a937f4a38ec3902a1201622b419bba960778ef79 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Fri, 10 Apr 2026 09:43:23 -0400 Subject: [PATCH 2/5] better artist rg -> release picking --- discover.go | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/discover.go b/discover.go index 9fe5b45..cf4e653 100644 --- a/discover.go +++ b/discover.go @@ -91,9 +91,9 @@ func searchMBArtists(query string) ([]mbArtist, error) { func releaseFormatScore(r mbRelease) int { for _, m := range r.Media { switch m.Format { - case "CD": - return 2 case "Digital Media": + return 2 + case "CD": return 1 } } @@ -104,16 +104,28 @@ func releaseFormatScore(r mbRelease) int { // 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 2 + case "KR": return 1 } return 0 } +// returns true if strings formatted 'YYYY-MM-DD" ts1 is before ts2 +func timeStringIsBefore(ts1, ts2 string) (bool, error) { + datefmt := "2006-02-01" + t1, err := time.Parse(datefmt, ts1) + if err != nil { + return false, err + } + t2, err := time.Parse(datefmt, ts2) + if err != nil { + return false, err + } + return t1.Unix() <= t2.Unix(), nil +} + // pickBestRelease selects the preferred release from a list. // Format (CD > Digital Media > *) is the primary sort key; // country (KR > JP > XW > *) breaks ties. @@ -124,9 +136,11 @@ func pickBestRelease(releases []mbRelease) *mbRelease { 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 + if before, err := timeStringIsBefore(r.Date, best.Date); before && err == nil { + rf, bf := releaseFormatScore(*r), releaseFormatScore(*best) + if rf > bf || (rf == bf && releaseCountryScore(*r) > releaseCountryScore(*best)) { + best = r + } } } return best From b910e32d6c289e784409fdfc00e70cbb7a1d565f Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Fri, 10 Apr 2026 10:18:19 -0400 Subject: [PATCH 3/5] text representation and disambiguation to releases --- discover.go | 12 +- static/app.js | 195 +++++++------ static/style.css | 736 ++++++++++++++++++++++++++++------------------- 3 files changed, 552 insertions(+), 391 deletions(-) diff --git a/discover.go b/discover.go index cf4e653..2158ed5 100644 --- a/discover.go +++ b/discover.go @@ -26,10 +26,14 @@ type mbMedia struct { } type mbRelease struct { - ID string `json:"id"` - Title string `json:"title"` - Date string `json:"date"` - Country string `json:"country"` + ID string `json:"id"` + Title string `json:"title"` + Date string `json:"date"` + Country string `json:"country"` + Disambiguation string `json:"disambiguation"` + TextRepresentation struct { + Language string `json:"language"` + } `json:"text-representation"` Media []mbMedia `json:"media"` ArtistCredit []mbArtistCredit `json:"artist-credit"` ReleaseGroup struct { diff --git a/static/app.js b/static/app.js index c06e801..e4e4104 100644 --- a/static/app.js +++ b/static/app.js @@ -1,9 +1,9 @@ -'use strict'; +"use strict"; // IDs of fetch cards we've already created, so we don't duplicate them. const knownFetchIds = new Set(); -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener("DOMContentLoaded", () => { initTabs(); initSearch(); initFetchList(); @@ -12,105 +12,124 @@ document.addEventListener('DOMContentLoaded', () => { // ── Tabs ─────────────────────────────────────────────────────────────────────── function initTabs() { - document.querySelector('.tabs').addEventListener('click', e => { - const btn = e.target.closest('.tab-btn'); + document.querySelector(".tabs").addEventListener("click", (e) => { + const btn = e.target.closest(".tab-btn"); if (!btn) return; showTab(btn.dataset.tab); }); } function showTab(name) { - document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); - document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); - document.getElementById('tab-' + name).classList.add('active'); - document.querySelector(`.tab-btn[data-tab="${name}"]`).classList.add('active'); + document + .querySelectorAll(".tab-pane") + .forEach((p) => p.classList.remove("active")); + document + .querySelectorAll(".tab-btn") + .forEach((b) => b.classList.remove("active")); + document.getElementById("tab-" + name).classList.add("active"); + document + .querySelector(`.tab-btn[data-tab="${name}"]`) + .classList.add("active"); } // ── Search ───────────────────────────────────────────────────────────────────── -let searchType = 'release'; +let searchType = "release"; function initSearch() { - document.querySelector('.type-toggle').addEventListener('click', e => { - const btn = e.target.closest('.type-btn'); + document.querySelector(".type-toggle").addEventListener("click", (e) => { + const btn = e.target.closest(".type-btn"); if (btn) setSearchType(btn.dataset.type); }); - const searchBtn = document.getElementById('search-btn'); - const searchInput = document.getElementById('search-q'); - searchBtn.addEventListener('click', doSearch); - searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); + const searchBtn = document.getElementById("search-btn"); + const searchInput = document.getElementById("search-q"); + searchBtn.addEventListener("click", doSearch); + searchInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") doSearch(); + }); // Event delegation for dynamically rendered result buttons - document.getElementById('search-results').addEventListener('click', e => { - const btn = e.target.closest('.fetch-btn'); + document.getElementById("search-results").addEventListener("click", (e) => { + const btn = e.target.closest(".fetch-btn"); if (!btn || btn.disabled) return; - if (btn.dataset.fetchType === 'artist') startArtistFetch(btn); + if (btn.dataset.fetchType === "artist") startArtistFetch(btn); else startReleaseFetch(btn); }); } function setSearchType(type) { searchType = type; - document.querySelectorAll('.type-btn').forEach(b => { - b.classList.toggle('active', b.dataset.type === type); + document.querySelectorAll(".type-btn").forEach((b) => { + b.classList.toggle("active", b.dataset.type === type); }); } function doSearch() { - const q = document.getElementById('search-q').value.trim(); + const q = document.getElementById("search-q").value.trim(); if (!q) return; - const btn = document.getElementById('search-btn'); - const resultsEl = document.getElementById('search-results'); + const btn = document.getElementById("search-btn"); + const resultsEl = document.getElementById("search-results"); btn.disabled = true; - btn.textContent = 'Searching\u2026'; + btn.textContent = "Searching\u2026"; resultsEl.innerHTML = '

Searching MusicBrainz\u2026

'; fetch(`/discover/search?q=${encodeURIComponent(q)}&type=${searchType}`) - .then(r => { - if (!r.ok) return r.text().then(t => { throw new Error(t || r.statusText); }); + .then((r) => { + if (!r.ok) + return r.text().then((t) => { + throw new Error(t || r.statusText); + }); return r.json(); }) - .then(data => renderResults(data)) - .catch(err => { + .then((data) => renderResults(data)) + .catch((err) => { resultsEl.innerHTML = `

Error: ${esc(err.message)}

`; }) .finally(() => { btn.disabled = false; - btn.textContent = 'Search'; + btn.textContent = "Search"; }); } // ── Results rendering ────────────────────────────────────────────────────────── function renderResults(data) { - const el = document.getElementById('search-results'); + const el = document.getElementById("search-results"); if (!data || data.length === 0) { el.innerHTML = '

No results found.

'; return; } - const renderer = searchType === 'artist' ? renderArtist : renderRelease; - el.innerHTML = data.map(renderer).join(''); + const renderer = searchType === "artist" ? renderArtist : renderRelease; + el.innerHTML = data.map(renderer).join(""); } function renderRelease(r) { - const credits = r['artist-credit'] ?? []; - 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 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 credits = r["artist-credit"] ?? []; + 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 country = r.country ?? ""; + const formats = [ + ...new Set((r.media ?? []).map((m) => m.format).filter(Boolean)), + ].join("+"); + const lang = r["text-representation"]?.language ?? ""; + const meta = [year, type, formats, country, lang] + .filter(Boolean) + .join(" \u00b7 "); + const dis = r.disambiguation ? ` (${esc(r.disambiguation)})` : ""; const coverUrl = `https://coverartarchive.org/release/${r.id}/front-250`; return `
- ${esc(artist)} \u2014 ${esc(r.title)} - ${meta ? `${esc(meta)}` : ''} + ${esc(artist)} \u2014 ${esc(r.title)}${dis} + ${meta ? `${esc(meta)}` : ""}