Compare commits

...

2 commits
v0.3.4 ... main

5 changed files with 171 additions and 30 deletions

View file

@ -23,6 +23,7 @@ type mbArtistCredit struct {
type mbMedia struct {
Format string `json:"format"`
TrackCount int `json:"track-count"`
}
type mbRelease struct {
@ -55,6 +56,22 @@ type mbReleaseGroup struct {
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 {
req, err := http.NewRequest("GET", "https://musicbrainz.org"+path, nil)
if err != nil {
@ -131,8 +148,8 @@ func timeStringIsBefore(ts1, ts2 string) (bool, error) {
}
// pickBestRelease selects the preferred release from a list.
// Format (CD > Digital Media > *) is the primary sort key;
// country (KR > JP > XW > *) breaks ties.
// No disambiguation (canonical release) is the primary sort key;
// format (CD > Digital Media > *) is secondary; country (KR > XW > *) breaks ties.
func pickBestRelease(releases []mbRelease) *mbRelease {
if len(releases) == 0 {
return nil
@ -140,6 +157,20 @@ func pickBestRelease(releases []mbRelease) *mbRelease {
best := &releases[0]
for i := 1; i < len(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 {
rf, bf := releaseFormatScore(*r), releaseFormatScore(*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
rel := pickBestReleaseForGroup(rg.ID)
releaseMBID := ""
trackCount := 0
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
trackCount = releaseTrackCount(*rel)
format := ""
if len(rel.Media) > 0 {
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 {
log.Printf("[discover] fetch failed for %q by %s: %v", rg.Title, artistName, err)
logf(fmt.Sprintf(" ↳ failed: %v", err))
@ -244,7 +277,7 @@ func fetchArtist(artistMBID, artistName string, logf func(string)) error {
continue
}
// 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))
}
@ -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)
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() {
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 {
log.Printf("[discover] fetch failed for %q by %s: %v", body.Album, body.Artist, err)
entry.finish(err)
return
}
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
}()

View file

@ -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)
result.CoverArt.Err = EmbedAlbumArtIntoFolder(albumPath)
if coverImg, err := FindCoverImage(albumPath); err == nil {

View file

@ -157,6 +157,58 @@ func fetchCoverArtArchiveFront(mbid string) ([]byte, string, error) {
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
// -------------------------

View file

@ -21,6 +21,7 @@ type pendingDownload struct {
Dir string // remote directory path on the peer
Files []slskdFile // files that were queued for download
Entry *fetchEntry // fetch card to update with import progress
TrackCount int // expected number of audio tracks (0 = unknown, skip check)
}
var (
@ -31,9 +32,11 @@ var (
// registerDownload records a queued slskd download for monitoring and eventual
// 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).
// 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
// 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{
ID: id,
BeetsMBID: beetsMBID,
@ -43,6 +46,7 @@ func registerDownload(id, beetsMBID, artist, album string, folder *albumFolder,
Dir: folder.Dir,
Files: folder.Files,
Entry: entry,
TrackCount: trackCount,
}
if entry == nil {
@ -56,8 +60,8 @@ func registerDownload(id, beetsMBID, artist, album string, folder *albumFolder,
pendingDownloads[id] = pd
pendingMu.Unlock()
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))
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), trackCount)
}
// 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)))
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 {
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 {
entry.finish(fmt.Errorf("cover embed failed: %w", err))
return

View file

@ -384,9 +384,12 @@ func getSlskdTransfers(username string) ([]slskdTransferDir, error) {
// fetchRelease searches slskd for an album, queues the best-quality match for
// 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).
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
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("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)
}
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)",
best.Dir, best.Username, qualityLabel(best.Quality), len(best.Files))
logf(fmt.Sprintf("Selected folder: %s", best.Dir))