mirror of
https://github.com/gabehf/music-importer.git
synced 2026-04-22 19:41:51 -07:00
Compare commits
No commits in common. "main" and "v0.3.4" have entirely different histories.
5 changed files with 30 additions and 171 deletions
58
discover.go
58
discover.go
|
|
@ -23,7 +23,6 @@ type mbArtistCredit struct {
|
||||||
|
|
||||||
type mbMedia struct {
|
type mbMedia struct {
|
||||||
Format string `json:"format"`
|
Format string `json:"format"`
|
||||||
TrackCount int `json:"track-count"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type mbRelease struct {
|
type mbRelease struct {
|
||||||
|
|
@ -56,22 +55,6 @@ type mbReleaseGroup struct {
|
||||||
FirstReleaseDate string `json:"first-release-date"`
|
FirstReleaseDate string `json:"first-release-date"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// releaseTrackCount returns the total number of tracks across all media in a release.
|
|
||||||
func releaseTrackCount(r mbRelease) int {
|
|
||||||
total := 0
|
|
||||||
for _, m := range r.Media {
|
|
||||||
total += m.TrackCount
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
// getMBRelease fetches a single release by MBID (with media/track-count included).
|
|
||||||
func getMBRelease(mbid string) (*mbRelease, error) {
|
|
||||||
var r mbRelease
|
|
||||||
err := mbGet(fmt.Sprintf("/ws/2/release/%s?fmt=json&inc=media", url.QueryEscape(mbid)), &r)
|
|
||||||
return &r, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func mbGet(path string, out interface{}) error {
|
func mbGet(path string, out interface{}) error {
|
||||||
req, err := http.NewRequest("GET", "https://musicbrainz.org"+path, nil)
|
req, err := http.NewRequest("GET", "https://musicbrainz.org"+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -148,8 +131,8 @@ func timeStringIsBefore(ts1, ts2 string) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickBestRelease selects the preferred release from a list.
|
// pickBestRelease selects the preferred release from a list.
|
||||||
// No disambiguation (canonical release) is the primary sort key;
|
// Format (CD > Digital Media > *) is the primary sort key;
|
||||||
// format (CD > Digital Media > *) is secondary; country (KR > XW > *) breaks ties.
|
// country (KR > JP > XW > *) breaks ties.
|
||||||
func pickBestRelease(releases []mbRelease) *mbRelease {
|
func pickBestRelease(releases []mbRelease) *mbRelease {
|
||||||
if len(releases) == 0 {
|
if len(releases) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -157,20 +140,6 @@ func pickBestRelease(releases []mbRelease) *mbRelease {
|
||||||
best := &releases[0]
|
best := &releases[0]
|
||||||
for i := 1; i < len(releases); i++ {
|
for i := 1; i < len(releases); i++ {
|
||||||
r := &releases[i]
|
r := &releases[i]
|
||||||
|
|
||||||
rNoDisamb := r.Disambiguation == ""
|
|
||||||
bestNoDisamb := best.Disambiguation == ""
|
|
||||||
|
|
||||||
// Prefer releases with no disambiguation — they are the canonical default.
|
|
||||||
if rNoDisamb && !bestNoDisamb {
|
|
||||||
best = r
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !rNoDisamb && bestNoDisamb {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both have the same disambiguation status; use date/format/country.
|
|
||||||
if before, err := timeStringIsBefore(r.Date, best.Date); before && err == nil {
|
if before, err := timeStringIsBefore(r.Date, best.Date); before && err == nil {
|
||||||
rf, bf := releaseFormatScore(*r), releaseFormatScore(*best)
|
rf, bf := releaseFormatScore(*r), releaseFormatScore(*best)
|
||||||
if rf > bf || (rf == bf && releaseCountryScore(*r) > releaseCountryScore(*best)) {
|
if rf > bf || (rf == bf && releaseCountryScore(*r) > releaseCountryScore(*best)) {
|
||||||
|
|
@ -256,20 +225,18 @@ func fetchArtist(artistMBID, artistName string, logf func(string)) error {
|
||||||
time.Sleep(time.Second) // MusicBrainz rate limit
|
time.Sleep(time.Second) // MusicBrainz rate limit
|
||||||
rel := pickBestReleaseForGroup(rg.ID)
|
rel := pickBestReleaseForGroup(rg.ID)
|
||||||
releaseMBID := ""
|
releaseMBID := ""
|
||||||
trackCount := 0
|
|
||||||
if rel == nil {
|
if rel == nil {
|
||||||
logf(fmt.Sprintf(" ↳ warning: could not resolve release for group %s, beets will search by name", rg.ID))
|
logf(fmt.Sprintf(" ↳ warning: could not resolve release for group %s, beets will search by name", rg.ID))
|
||||||
} else {
|
} else {
|
||||||
releaseMBID = rel.ID
|
releaseMBID = rel.ID
|
||||||
trackCount = releaseTrackCount(*rel)
|
|
||||||
format := ""
|
format := ""
|
||||||
if len(rel.Media) > 0 {
|
if len(rel.Media) > 0 {
|
||||||
format = rel.Media[0].Format
|
format = rel.Media[0].Format
|
||||||
}
|
}
|
||||||
logf(fmt.Sprintf(" ↳ selected release: %s [%s / %s / %d tracks]", releaseMBID, format, rel.Country, trackCount))
|
logf(fmt.Sprintf(" ↳ selected release: %s [%s / %s]", releaseMBID, format, rel.Country))
|
||||||
}
|
}
|
||||||
|
|
||||||
folder, err := fetchRelease(artistName, rg.Title, releaseMBID, trackCount, logf)
|
folder, err := fetchRelease(artistName, rg.Title, releaseMBID, logf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[discover] fetch failed for %q by %s: %v", rg.Title, artistName, err)
|
log.Printf("[discover] fetch failed for %q by %s: %v", rg.Title, artistName, err)
|
||||||
logf(fmt.Sprintf(" ↳ failed: %v", err))
|
logf(fmt.Sprintf(" ↳ failed: %v", err))
|
||||||
|
|
@ -277,7 +244,7 @@ func fetchArtist(artistMBID, artistName string, logf func(string)) error {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Key the pending download by release group ID for dedup; beets uses releaseMBID.
|
// Key the pending download by release group ID for dedup; beets uses releaseMBID.
|
||||||
registerDownload(rg.ID, releaseMBID, artistName, rg.Title, trackCount, folder, nil)
|
registerDownload(rg.ID, releaseMBID, artistName, rg.Title, folder, nil)
|
||||||
logf(fmt.Sprintf(" ↳ registered for import (release mbid: %s)", releaseMBID))
|
logf(fmt.Sprintf(" ↳ registered for import (release mbid: %s)", releaseMBID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -416,26 +383,15 @@ func handleDiscoverFetch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
log.Printf("[discover] starting fetch: %q by %s (mbid: %s)", body.Album, body.Artist, body.ID)
|
log.Printf("[discover] starting fetch: %q by %s (mbid: %s)", body.Album, body.Artist, body.ID)
|
||||||
entry := newFetchEntry(body.ID, body.Artist, body.Album)
|
entry := newFetchEntry(body.ID, body.Artist, body.Album)
|
||||||
|
|
||||||
// Look up the expected track count from MusicBrainz so the folder-selection
|
|
||||||
// logic can prefer results that match the release we intend to import.
|
|
||||||
trackCount := 0
|
|
||||||
if rel, err := getMBRelease(body.ID); err == nil {
|
|
||||||
trackCount = releaseTrackCount(*rel)
|
|
||||||
log.Printf("[discover] release %s has %d tracks", body.ID, trackCount)
|
|
||||||
} else {
|
|
||||||
log.Printf("[discover] could not fetch release track count for %s: %v", body.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
folder, err := fetchRelease(body.Artist, body.Album, body.ID, trackCount, entry.appendLog)
|
folder, err := fetchRelease(body.Artist, body.Album, body.ID, entry.appendLog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[discover] fetch failed for %q by %s: %v", body.Album, body.Artist, err)
|
log.Printf("[discover] fetch failed for %q by %s: %v", body.Album, body.Artist, err)
|
||||||
entry.finish(err)
|
entry.finish(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[discover] fetch complete for %q by %s, registering for import", body.Album, body.Artist)
|
log.Printf("[discover] fetch complete for %q by %s, registering for import", body.Album, body.Artist)
|
||||||
registerDownload(body.ID, body.ID, body.Artist, body.Album, trackCount, folder, entry)
|
registerDownload(body.ID, body.ID, body.Artist, body.Album, folder, entry)
|
||||||
// entry.finish is called by the monitor when import completes
|
// entry.finish is called by the monitor when import completes
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -218,10 +218,6 @@ func RunImporter() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := NormalizeCoverArt(albumPath); err != nil {
|
|
||||||
fmt.Println("Cover art normalization warning:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("→ Embedding cover art for album:", albumPath)
|
fmt.Println("→ Embedding cover art for album:", albumPath)
|
||||||
result.CoverArt.Err = EmbedAlbumArtIntoFolder(albumPath)
|
result.CoverArt.Err = EmbedAlbumArtIntoFolder(albumPath)
|
||||||
if coverImg, err := FindCoverImage(albumPath); err == nil {
|
if coverImg, err := FindCoverImage(albumPath); err == nil {
|
||||||
|
|
|
||||||
52
media.go
52
media.go
|
|
@ -157,58 +157,6 @@ func fetchCoverArtArchiveFront(mbid string) ([]byte, string, error) {
|
||||||
return data, ext, nil
|
return data, ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverMaxBytes = 5 * 1024 * 1024 // 5 MB
|
|
||||||
|
|
||||||
// NormalizeCoverArt checks whether the cover image in albumDir is a large
|
|
||||||
// non-JPEG (>5 MB). If so, it converts it to JPEG and resizes it to at most
|
|
||||||
// 2000×2000 pixels using ffmpeg, replacing the original file with cover.jpg.
|
|
||||||
// The function is a no-op when no cover is found, the cover is already JPEG,
|
|
||||||
// or the file is ≤5 MB.
|
|
||||||
func NormalizeCoverArt(albumDir string) error {
|
|
||||||
cover, err := FindCoverImage(albumDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil // no cover present, nothing to do
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already JPEG — no conversion needed regardless of size.
|
|
||||||
ext := strings.ToLower(filepath.Ext(cover))
|
|
||||||
if ext == ".jpg" || ext == ".jpeg" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(cover)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("stat cover: %w", err)
|
|
||||||
}
|
|
||||||
if info.Size() <= coverMaxBytes {
|
|
||||||
return nil // small enough, leave as-is
|
|
||||||
}
|
|
||||||
|
|
||||||
dest := filepath.Join(albumDir, "cover.jpg")
|
|
||||||
fmt.Printf("→ Cover art is %.1f MB %s; converting to JPEG (max 2000×2000)…\n",
|
|
||||||
float64(info.Size())/(1024*1024), strings.ToUpper(strings.TrimPrefix(ext, ".")))
|
|
||||||
|
|
||||||
// scale=2000:2000:force_original_aspect_ratio=decrease fits the image within
|
|
||||||
// 2000×2000 while preserving aspect ratio, and never upscales smaller images.
|
|
||||||
cmd := exec.Command("ffmpeg", "-y", "-i", cover,
|
|
||||||
"-vf", "scale=2000:2000:force_original_aspect_ratio=decrease",
|
|
||||||
"-q:v", "2",
|
|
||||||
dest,
|
|
||||||
)
|
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("ffmpeg cover conversion failed: %w\n%s", err, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cover != dest {
|
|
||||||
if err := os.Remove(cover); err != nil {
|
|
||||||
fmt.Println("Warning: could not remove original cover:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("→ Converted cover art to JPEG:", filepath.Base(dest))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Find cover image
|
// Find cover image
|
||||||
// -------------------------
|
// -------------------------
|
||||||
|
|
|
||||||
22
monitor.go
22
monitor.go
|
|
@ -21,7 +21,6 @@ type pendingDownload struct {
|
||||||
Dir string // remote directory path on the peer
|
Dir string // remote directory path on the peer
|
||||||
Files []slskdFile // files that were queued for download
|
Files []slskdFile // files that were queued for download
|
||||||
Entry *fetchEntry // fetch card to update with import progress
|
Entry *fetchEntry // fetch card to update with import progress
|
||||||
TrackCount int // expected number of audio tracks (0 = unknown, skip check)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -32,11 +31,9 @@ var (
|
||||||
// registerDownload records a queued slskd download for monitoring and eventual
|
// registerDownload records a queued slskd download for monitoring and eventual
|
||||||
// auto-import. id is used as the dedup key; beetsMBID is the release MBID
|
// auto-import. id is used as the dedup key; beetsMBID is the release MBID
|
||||||
// forwarded to beets --search-id (may be empty or differ from id).
|
// forwarded to beets --search-id (may be empty or differ from id).
|
||||||
// trackCount is the expected number of audio tracks from MusicBrainz; 0 means
|
|
||||||
// unknown and the sanity check will be skipped at import time.
|
|
||||||
// If entry is nil a new fetchEntry is created so the frontend can discover it
|
// If entry is nil a new fetchEntry is created so the frontend can discover it
|
||||||
// via /discover/fetch/list.
|
// via /discover/fetch/list.
|
||||||
func registerDownload(id, beetsMBID, artist, album string, trackCount int, folder *albumFolder, entry *fetchEntry) {
|
func registerDownload(id, beetsMBID, artist, album string, folder *albumFolder, entry *fetchEntry) {
|
||||||
pd := &pendingDownload{
|
pd := &pendingDownload{
|
||||||
ID: id,
|
ID: id,
|
||||||
BeetsMBID: beetsMBID,
|
BeetsMBID: beetsMBID,
|
||||||
|
|
@ -46,7 +43,6 @@ func registerDownload(id, beetsMBID, artist, album string, trackCount int, folde
|
||||||
Dir: folder.Dir,
|
Dir: folder.Dir,
|
||||||
Files: folder.Files,
|
Files: folder.Files,
|
||||||
Entry: entry,
|
Entry: entry,
|
||||||
TrackCount: trackCount,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry == nil {
|
if entry == nil {
|
||||||
|
|
@ -60,8 +56,8 @@ func registerDownload(id, beetsMBID, artist, album string, trackCount int, folde
|
||||||
pendingDownloads[id] = pd
|
pendingDownloads[id] = pd
|
||||||
pendingMu.Unlock()
|
pendingMu.Unlock()
|
||||||
|
|
||||||
log.Printf("[monitor] registered: %q by %s (id: %s, beets mbid: %s, peer: %s, %d files, expected tracks: %d)",
|
log.Printf("[monitor] registered: %q by %s (id: %s, beets mbid: %s, peer: %s, %d files)",
|
||||||
album, artist, id, beetsMBID, folder.Username, len(folder.Files), trackCount)
|
album, artist, id, beetsMBID, folder.Username, len(folder.Files))
|
||||||
}
|
}
|
||||||
|
|
||||||
// startMonitor launches a background goroutine that periodically checks whether
|
// startMonitor launches a background goroutine that periodically checks whether
|
||||||
|
|
@ -219,14 +215,6 @@ func importPendingRelease(pd *pendingDownload, localDir string) {
|
||||||
}
|
}
|
||||||
logf(fmt.Sprintf("Found %d tracks", len(tracks)))
|
logf(fmt.Sprintf("Found %d tracks", len(tracks)))
|
||||||
|
|
||||||
if pd.TrackCount > 0 && len(tracks) != pd.TrackCount {
|
|
||||||
entry.finish(fmt.Errorf(
|
|
||||||
"track count mismatch: downloaded %d tracks but release expects %d — aborting to avoid importing wrong edition",
|
|
||||||
len(tracks), pd.TrackCount,
|
|
||||||
))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cleanAlbumTags(localDir); err != nil {
|
if err := cleanAlbumTags(localDir); err != nil {
|
||||||
logf(fmt.Sprintf("Clean tags warning: %v", err))
|
logf(fmt.Sprintf("Clean tags warning: %v", err))
|
||||||
}
|
}
|
||||||
|
|
@ -254,10 +242,6 @@ func importPendingRelease(pd *pendingDownload, localDir string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := NormalizeCoverArt(localDir); err != nil {
|
|
||||||
logf(fmt.Sprintf("Cover art normalization warning: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := EmbedAlbumArtIntoFolder(localDir); err != nil {
|
if err := EmbedAlbumArtIntoFolder(localDir); err != nil {
|
||||||
entry.finish(fmt.Errorf("cover embed failed: %w", err))
|
entry.finish(fmt.Errorf("cover embed failed: %w", err))
|
||||||
return
|
return
|
||||||
|
|
|
||||||
31
slskd.go
31
slskd.go
|
|
@ -384,12 +384,9 @@ func getSlskdTransfers(username string) ([]slskdTransferDir, error) {
|
||||||
// fetchRelease searches slskd for an album, queues the best-quality match for
|
// fetchRelease searches slskd for an album, queues the best-quality match for
|
||||||
// download, and returns the chosen folder so the caller can monitor completion.
|
// download, and returns the chosen folder so the caller can monitor completion.
|
||||||
// mbid, if non-empty, will be stored for use during import (beets --search-id).
|
// mbid, if non-empty, will be stored for use during import (beets --search-id).
|
||||||
// trackCount, if > 0, filters candidate folders to those whose audio file count
|
func fetchRelease(artist, album, mbid string, logf func(string)) (*albumFolder, error) {
|
||||||
// matches the expected number of tracks on the release, so alternate editions
|
|
||||||
// with different track counts are not accidentally selected.
|
|
||||||
func fetchRelease(artist, album, mbid string, trackCount int, logf func(string)) (*albumFolder, error) {
|
|
||||||
query := artist + " " + album
|
query := artist + " " + album
|
||||||
log.Printf("[discover] fetch started: %q by %s (expected tracks: %d)", album, artist, trackCount)
|
log.Printf("[discover] fetch started: %q by %s", album, artist)
|
||||||
logf("Starting fetch for: " + query)
|
logf("Starting fetch for: " + query)
|
||||||
|
|
||||||
logf("Creating slskd search…")
|
logf("Creating slskd search…")
|
||||||
|
|
@ -421,29 +418,7 @@ func fetchRelease(artist, album, mbid string, trackCount int, logf func(string))
|
||||||
return nil, fmt.Errorf("no audio files found for %q by %s", album, artist)
|
return nil, fmt.Errorf("no audio files found for %q by %s", album, artist)
|
||||||
}
|
}
|
||||||
|
|
||||||
// When we know the expected track count, prefer folders that match exactly
|
best := bestAlbumFolder(folders)
|
||||||
// so we don't accidentally grab a bonus-track edition or a different version
|
|
||||||
// that won't align with the release MBID we pass to beets.
|
|
||||||
candidates := folders
|
|
||||||
if trackCount > 0 {
|
|
||||||
var matched []albumFolder
|
|
||||||
for _, f := range folders {
|
|
||||||
if len(f.Files) == trackCount {
|
|
||||||
matched = append(matched, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(matched) > 0 {
|
|
||||||
log.Printf("[discover] %d/%d folders match expected track count (%d)", len(matched), len(folders), trackCount)
|
|
||||||
logf(fmt.Sprintf("Filtered to %d/%d folders matching expected track count (%d)",
|
|
||||||
len(matched), len(folders), trackCount))
|
|
||||||
candidates = matched
|
|
||||||
} else {
|
|
||||||
log.Printf("[discover] no folders matched expected track count (%d); using best available", trackCount)
|
|
||||||
logf(fmt.Sprintf("Warning: no folders matched expected track count (%d); using best available", trackCount))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
best := bestAlbumFolder(candidates)
|
|
||||||
log.Printf("[discover] selected folder: %s from %s (%s, %d files)",
|
log.Printf("[discover] selected folder: %s from %s (%s, %d files)",
|
||||||
best.Dir, best.Username, qualityLabel(best.Quality), len(best.Files))
|
best.Dir, best.Username, qualityLabel(best.Quality), len(best.Files))
|
||||||
logf(fmt.Sprintf("Selected folder: %s", best.Dir))
|
logf(fmt.Sprintf("Selected folder: %s", best.Dir))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue