mirror of
https://github.com/gabehf/music-importer.git
synced 2026-04-22 03:21: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"`
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
media.go
15
media.go
|
|
@ -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 {
|
||||
|
|
|
|||
25
metadata.go
25
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,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>"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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.
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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>` : ''}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue