diff --git a/discover.go b/discover.go index cf4e653..ddd38e6 100644 --- a/discover.go +++ b/discover.go @@ -22,14 +22,19 @@ type mbArtistCredit struct { } type mbMedia struct { - Format string `json:"format"` + Format string `json:"format"` + TrackCount int `json:"track-count"` } type mbRelease struct { - ID string `json:"id"` - Title string `json:"title"` - Date string `json:"date"` - Country string `json:"country"` + ID string `json:"id"` + Title string `json:"title"` + Date string `json:"date"` + Country string `json:"country"` + Disambiguation string `json:"disambiguation"` + TextRepresentation struct { + Language string `json:"language"` + } `json:"text-representation"` Media []mbMedia `json:"media"` ArtistCredit []mbArtistCredit `json:"artist-credit"` ReleaseGroup struct { @@ -51,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 { @@ -127,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 @@ -136,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)) { @@ -221,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)) @@ -240,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)) } @@ -379,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 }() diff --git a/importer.go b/importer.go index 9252ee2..d108f16 100644 --- a/importer.go +++ b/importer.go @@ -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 { diff --git a/media.go b/media.go index 232f845..e0274a6 100644 --- a/media.go +++ b/media.go @@ -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 // ------------------------- diff --git a/monitor.go b/monitor.go index 68dc608..c09438b 100644 --- a/monitor.go +++ b/monitor.go @@ -13,14 +13,15 @@ import ( // pendingDownload tracks a queued slskd download that should be auto-imported // once all files have transferred successfully. type pendingDownload struct { - 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) - Artist string - Album string - Username string // slskd peer username - 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 + 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) + Artist string + Album string + Username string // slskd peer username + 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,18 +32,21 @@ 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, - Artist: artist, - Album: album, - Username: folder.Username, - Dir: folder.Dir, - Files: folder.Files, - Entry: entry, + ID: id, + BeetsMBID: beetsMBID, + Artist: artist, + Album: album, + Username: folder.Username, + 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 diff --git a/slskd.go b/slskd.go index 286f1e6..70fcad9 100644 --- a/slskd.go +++ b/slskd.go @@ -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)) diff --git a/static/app.js b/static/app.js index c06e801..e4e4104 100644 --- a/static/app.js +++ b/static/app.js @@ -1,9 +1,9 @@ -'use strict'; +"use strict"; // IDs of fetch cards we've already created, so we don't duplicate them. const knownFetchIds = new Set(); -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener("DOMContentLoaded", () => { initTabs(); initSearch(); initFetchList(); @@ -12,105 +12,124 @@ document.addEventListener('DOMContentLoaded', () => { // ── Tabs ─────────────────────────────────────────────────────────────────────── function initTabs() { - document.querySelector('.tabs').addEventListener('click', e => { - const btn = e.target.closest('.tab-btn'); + document.querySelector(".tabs").addEventListener("click", (e) => { + const btn = e.target.closest(".tab-btn"); if (!btn) return; showTab(btn.dataset.tab); }); } function showTab(name) { - document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); - document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); - document.getElementById('tab-' + name).classList.add('active'); - document.querySelector(`.tab-btn[data-tab="${name}"]`).classList.add('active'); + document + .querySelectorAll(".tab-pane") + .forEach((p) => p.classList.remove("active")); + document + .querySelectorAll(".tab-btn") + .forEach((b) => b.classList.remove("active")); + document.getElementById("tab-" + name).classList.add("active"); + document + .querySelector(`.tab-btn[data-tab="${name}"]`) + .classList.add("active"); } // ── Search ───────────────────────────────────────────────────────────────────── -let searchType = 'release'; +let searchType = "release"; function initSearch() { - document.querySelector('.type-toggle').addEventListener('click', e => { - const btn = e.target.closest('.type-btn'); + document.querySelector(".type-toggle").addEventListener("click", (e) => { + const btn = e.target.closest(".type-btn"); if (btn) setSearchType(btn.dataset.type); }); - const searchBtn = document.getElementById('search-btn'); - const searchInput = document.getElementById('search-q'); - searchBtn.addEventListener('click', doSearch); - searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); + const searchBtn = document.getElementById("search-btn"); + const searchInput = document.getElementById("search-q"); + searchBtn.addEventListener("click", doSearch); + searchInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") doSearch(); + }); // Event delegation for dynamically rendered result buttons - document.getElementById('search-results').addEventListener('click', e => { - const btn = e.target.closest('.fetch-btn'); + document.getElementById("search-results").addEventListener("click", (e) => { + const btn = e.target.closest(".fetch-btn"); if (!btn || btn.disabled) return; - if (btn.dataset.fetchType === 'artist') startArtistFetch(btn); + if (btn.dataset.fetchType === "artist") startArtistFetch(btn); else startReleaseFetch(btn); }); } function setSearchType(type) { searchType = type; - document.querySelectorAll('.type-btn').forEach(b => { - b.classList.toggle('active', b.dataset.type === type); + document.querySelectorAll(".type-btn").forEach((b) => { + b.classList.toggle("active", b.dataset.type === type); }); } function doSearch() { - const q = document.getElementById('search-q').value.trim(); + const q = document.getElementById("search-q").value.trim(); if (!q) return; - const btn = document.getElementById('search-btn'); - const resultsEl = document.getElementById('search-results'); + const btn = document.getElementById("search-btn"); + const resultsEl = document.getElementById("search-results"); btn.disabled = true; - btn.textContent = 'Searching\u2026'; + btn.textContent = "Searching\u2026"; resultsEl.innerHTML = '

Searching MusicBrainz\u2026

'; fetch(`/discover/search?q=${encodeURIComponent(q)}&type=${searchType}`) - .then(r => { - if (!r.ok) return r.text().then(t => { throw new Error(t || r.statusText); }); + .then((r) => { + if (!r.ok) + return r.text().then((t) => { + throw new Error(t || r.statusText); + }); return r.json(); }) - .then(data => renderResults(data)) - .catch(err => { + .then((data) => renderResults(data)) + .catch((err) => { resultsEl.innerHTML = `

Error: ${esc(err.message)}

`; }) .finally(() => { btn.disabled = false; - btn.textContent = 'Search'; + btn.textContent = "Search"; }); } // ── Results rendering ────────────────────────────────────────────────────────── function renderResults(data) { - const el = document.getElementById('search-results'); + const el = document.getElementById("search-results"); if (!data || data.length === 0) { el.innerHTML = '

No results found.

'; return; } - const renderer = searchType === 'artist' ? renderArtist : renderRelease; - el.innerHTML = data.map(renderer).join(''); + const renderer = searchType === "artist" ? renderArtist : renderRelease; + el.innerHTML = data.map(renderer).join(""); } function renderRelease(r) { - const credits = r['artist-credit'] ?? []; - 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 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 credits = r["artist-credit"] ?? []; + 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 country = r.country ?? ""; + const formats = [ + ...new Set((r.media ?? []).map((m) => m.format).filter(Boolean)), + ].join("+"); + const lang = r["text-representation"]?.language ?? ""; + const meta = [year, type, formats, country, lang] + .filter(Boolean) + .join(" \u00b7 "); + const dis = r.disambiguation ? ` (${esc(r.disambiguation)})` : ""; const coverUrl = `https://coverartarchive.org/release/${r.id}/front-250`; return `
- ${esc(artist)} \u2014 ${esc(r.title)} - ${meta ? `${esc(meta)}` : ''} + ${esc(artist)} \u2014 ${esc(r.title)}${dis} + ${meta ? `${esc(meta)}` : ""}