mirror of
https://github.com/gabehf/music-importer.git
synced 2026-04-22 11:31:52 -07:00
better release search. artist search just ok
This commit is contained in:
parent
4324de1271
commit
eca7f4ba31
8 changed files with 121 additions and 38 deletions
91
discover.go
91
discover.go
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
media.go
19
media.go
|
|
@ -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)
|
||||||
|
|
|
||||||
29
metadata.go
29
metadata.go
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
slskd.go
2
slskd.go
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>` : ''}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue