mirror of
https://github.com/gabehf/music-importer.git
synced 2026-04-22 11:31:52 -07:00
Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93a5fc411e | |||
| 769f3ff08c |
5 changed files with 171 additions and 30 deletions
60
discover.go
60
discover.go
|
|
@ -22,7 +22,8 @@ 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 {
|
||||||
|
|
@ -55,6 +56,22 @@ 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 {
|
||||||
|
|
@ -131,8 +148,8 @@ func timeStringIsBefore(ts1, ts2 string) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickBestRelease selects the preferred release from a list.
|
// pickBestRelease selects the preferred release from a list.
|
||||||
// Format (CD > Digital Media > *) is the primary sort key;
|
// No disambiguation (canonical release) is the primary sort key;
|
||||||
// country (KR > JP > XW > *) breaks ties.
|
// format (CD > Digital Media > *) is secondary; country (KR > 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
|
||||||
|
|
@ -140,6 +157,20 @@ 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)) {
|
||||||
|
|
@ -225,18 +256,20 @@ 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]", releaseMBID, format, rel.Country))
|
logf(fmt.Sprintf(" ↳ selected release: %s [%s / %s / %d tracks]", releaseMBID, format, rel.Country, trackCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
folder, err := fetchRelease(artistName, rg.Title, releaseMBID, logf)
|
folder, err := fetchRelease(artistName, rg.Title, releaseMBID, trackCount, 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))
|
||||||
|
|
@ -244,7 +277,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, folder, nil)
|
registerDownload(rg.ID, releaseMBID, artistName, rg.Title, trackCount, folder, nil)
|
||||||
logf(fmt.Sprintf(" ↳ registered for import (release mbid: %s)", releaseMBID))
|
logf(fmt.Sprintf(" ↳ registered for import (release mbid: %s)", releaseMBID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -383,15 +416,26 @@ 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, entry.appendLog)
|
folder, err := fetchRelease(body.Artist, body.Album, body.ID, trackCount, 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, folder, entry)
|
registerDownload(body.ID, body.ID, body.Artist, body.Album, trackCount, folder, entry)
|
||||||
// entry.finish is called by the monitor when import completes
|
// entry.finish is called by the monitor when import completes
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,10 @@ 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,6 +157,58 @@ 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
|
||||||
// -------------------------
|
// -------------------------
|
||||||
|
|
|
||||||
54
monitor.go
54
monitor.go
|
|
@ -13,14 +13,15 @@ import (
|
||||||
// pendingDownload tracks a queued slskd download that should be auto-imported
|
// pendingDownload tracks a queued slskd download that should be auto-imported
|
||||||
// once all files have transferred successfully.
|
// once all files have transferred successfully.
|
||||||
type pendingDownload struct {
|
type pendingDownload struct {
|
||||||
ID string // dedup key (release MBID for single fetches; release group MBID for artist fetches)
|
ID string // dedup key (release MBID for single fetches; release group MBID for artist fetches)
|
||||||
BeetsMBID string // release MBID passed to beets --search-id (may differ from ID)
|
BeetsMBID string // release MBID passed to beets --search-id (may differ from ID)
|
||||||
Artist string
|
Artist string
|
||||||
Album string
|
Album string
|
||||||
Username string // slskd peer username
|
Username string // slskd peer username
|
||||||
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 (
|
||||||
|
|
@ -31,18 +32,21 @@ 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, folder *albumFolder, entry *fetchEntry) {
|
func registerDownload(id, beetsMBID, artist, album string, trackCount int, folder *albumFolder, entry *fetchEntry) {
|
||||||
pd := &pendingDownload{
|
pd := &pendingDownload{
|
||||||
ID: id,
|
ID: id,
|
||||||
BeetsMBID: beetsMBID,
|
BeetsMBID: beetsMBID,
|
||||||
Artist: artist,
|
Artist: artist,
|
||||||
Album: album,
|
Album: album,
|
||||||
Username: folder.Username,
|
Username: folder.Username,
|
||||||
Dir: folder.Dir,
|
Dir: folder.Dir,
|
||||||
Files: folder.Files,
|
Files: folder.Files,
|
||||||
Entry: entry,
|
Entry: entry,
|
||||||
|
TrackCount: trackCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry == nil {
|
if entry == nil {
|
||||||
|
|
@ -56,8 +60,8 @@ func registerDownload(id, beetsMBID, artist, album string, folder *albumFolder,
|
||||||
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)",
|
log.Printf("[monitor] registered: %q by %s (id: %s, beets mbid: %s, peer: %s, %d files, expected tracks: %d)",
|
||||||
album, artist, id, beetsMBID, folder.Username, len(folder.Files))
|
album, artist, id, beetsMBID, folder.Username, len(folder.Files), trackCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startMonitor launches a background goroutine that periodically checks whether
|
// startMonitor launches a background goroutine that periodically checks whether
|
||||||
|
|
@ -215,6 +219,14 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
@ -242,6 +254,10 @@ 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,9 +384,12 @@ 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).
|
||||||
func fetchRelease(artist, album, mbid string, logf func(string)) (*albumFolder, error) {
|
// trackCount, if > 0, filters candidate folders to those whose audio file count
|
||||||
|
// 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", album, artist)
|
log.Printf("[discover] fetch started: %q by %s (expected tracks: %d)", album, artist, trackCount)
|
||||||
logf("Starting fetch for: " + query)
|
logf("Starting fetch for: " + query)
|
||||||
|
|
||||||
logf("Creating slskd search…")
|
logf("Creating slskd search…")
|
||||||
|
|
@ -418,7 +421,29 @@ func fetchRelease(artist, album, mbid string, logf func(string)) (*albumFolder,
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
best := bestAlbumFolder(folders)
|
// When we know the expected track count, prefer folders that match exactly
|
||||||
|
// 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