diff --git a/discover.go b/discover.go index ddd38e6..c6f9ee9 100644 --- a/discover.go +++ b/discover.go @@ -21,21 +21,10 @@ type mbArtistCredit struct { } `json:"artist"` } -type mbMedia struct { - 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"` - Disambiguation string `json:"disambiguation"` - TextRepresentation struct { - Language string `json:"language"` - } `json:"text-representation"` - Media []mbMedia `json:"media"` + ID string `json:"id"` + Title string `json:"title"` + Date string `json:"date"` ArtistCredit []mbArtistCredit `json:"artist-credit"` ReleaseGroup struct { PrimaryType string `json:"primary-type"` @@ -56,22 +45,6 @@ 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 { @@ -95,7 +68,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&inc=media", &result) + err := mbGet("/ws/2/release/?query="+url.QueryEscape(query)+"&fmt=json&limit=20", &result) return result.Releases, err } @@ -107,94 +80,6 @@ func searchMBArtists(query string) ([]mbArtist, error) { return result.Artists, err } -// 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 "Digital Media": - return 2 - case "CD": - 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 "XW": - return 2 - case "KR": - return 1 - } - return 0 -} - -// returns true if strings formatted 'YYYY-MM-DD" ts1 is before ts2 -func timeStringIsBefore(ts1, ts2 string) (bool, error) { - datefmt := "2006-02-01" - t1, err := time.Parse(datefmt, ts1) - if err != nil { - return false, err - } - t2, err := time.Parse(datefmt, ts2) - if err != nil { - return false, err - } - return t1.Unix() <= t2.Unix(), nil -} - -// pickBestRelease selects the preferred release from a list. -// 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 - } - 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)) { - 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 []mbRelease `json:"releases"` - } - 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 nil - } - return pickBestRelease(result.Releases) -} - // getMBArtistReleaseGroups returns all Album and EP release groups for an artist, // paginating through the MusicBrainz browse API with the required 1 req/s rate limit. func getMBArtistReleaseGroups(artistMBID string) ([]mbReleaseGroup, error) { @@ -251,34 +136,15 @@ 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)) - // 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 - 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 / %d tracks]", releaseMBID, format, rel.Country, trackCount)) - } - - folder, err := fetchRelease(artistName, rg.Title, releaseMBID, trackCount, logf) + folder, err := fetchRelease(artistName, rg.Title, rg.ID, logf) if err != nil { log.Printf("[discover] fetch failed for %q by %s: %v", rg.Title, artistName, err) logf(fmt.Sprintf(" ↳ failed: %v", err)) failed++ continue } - // Key the pending download by release group ID for dedup; beets uses releaseMBID. - registerDownload(rg.ID, releaseMBID, artistName, rg.Title, trackCount, folder, nil) - logf(fmt.Sprintf(" ↳ registered for import (release mbid: %s)", releaseMBID)) + registerDownload(rg.ID, artistName, rg.Title, folder, nil) + logf(fmt.Sprintf(" ↳ registered for import (mbid: %s)", rg.ID)) } if failed > 0 { @@ -416,26 +282,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) 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, trackCount, entry.appendLog) + folder, err := fetchRelease(body.Artist, body.Album, body.ID, 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, trackCount, folder, entry) + registerDownload(body.ID, body.Artist, body.Album, folder, entry) // entry.finish is called by the monitor when import completes }() diff --git a/files.go b/files.go index 75a388c..943aebd 100644 --- a/files.go +++ b/files.go @@ -9,9 +9,8 @@ import ( "strings" ) -// albumTargetDir returns the destination directory for an album without -// creating it. Use this to check for an existing import before moving files. -func albumTargetDir(libDir string, md *MusicMetadata) string { +// moveToLibrary moves a file to {libDir}/{artist}/[{date}] {album} [{quality}]/filename. +func moveToLibrary(libDir string, md *MusicMetadata, srcPath string) error { date := md.Date if date == "" { date = md.Year @@ -20,12 +19,7 @@ func albumTargetDir(libDir string, md *MusicMetadata) string { if md.Quality != "" { albumDir += fmt.Sprintf(" [%s]", md.Quality) } - return filepath.Join(libDir, sanitize(md.Artist), sanitize(albumDir)) -} - -// moveToLibrary moves a file to {libDir}/{artist}/[{date}] {album} [{quality}]/filename. -func moveToLibrary(libDir string, md *MusicMetadata, srcPath string) error { - targetDir := albumTargetDir(libDir, md) + targetDir := filepath.Join(libDir, sanitize(md.Artist), sanitize(albumDir)) if err := os.MkdirAll(targetDir, 0755); err != nil { return err } diff --git a/importer.go b/importer.go index d108f16..6a27f9c 100644 --- a/importer.go +++ b/importer.go @@ -213,15 +213,11 @@ 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) } } - 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 { @@ -237,39 +233,33 @@ func RunImporter() { continue } - targetDir := albumTargetDir(libraryDir, md) - if _, err := os.Stat(targetDir); err == nil { - fmt.Println("→ Album already exists in library, skipping move:", targetDir) - result.Move.Skipped = true - } else { - fmt.Println("→ Moving tracks into library for album:", albumPath) - for _, track := range tracks { - if err := moveToLibrary(libraryDir, md, track); err != nil { - fmt.Println("Failed to move track:", track, err) - result.Move.Err = err // retains last error; all attempts are still made - } + fmt.Println("→ Moving tracks into library for album:", albumPath) + for _, track := range tracks { + if err := moveToLibrary(libraryDir, md, track); err != nil { + fmt.Println("Failed to move track:", track, err) + result.Move.Err = err // retains last error; all attempts are still made } - - lyrics, _ := getLyricFiles(albumPath) - - fmt.Println("→ Moving lyrics into library for album:", albumPath) - for _, file := range lyrics { - if err := moveToLibrary(libraryDir, md, file); err != nil { - fmt.Println("Failed to move lyrics:", file, err) - result.Move.Err = err - } - } - - fmt.Println("→ Moving album cover into library for album:", albumPath) - if coverImg, err := FindCoverImage(albumPath); err == nil { - if err := moveToLibrary(libraryDir, md, coverImg); err != nil { - fmt.Println("Failed to cover image:", coverImg, err) - result.Move.Err = err - } - } - - os.Remove(albumPath) } + + lyrics, _ := getLyricFiles(albumPath) + + fmt.Println("→ Moving lyrics into library for album:", albumPath) + for _, file := range lyrics { + if err := moveToLibrary(libraryDir, md, file); err != nil { + fmt.Println("Failed to move lyrics:", file, err) + result.Move.Err = err + } + } + + fmt.Println("→ Moving album cover into library for album:", albumPath) + if coverImg, err := FindCoverImage(albumPath); err == nil { + if err := moveToLibrary(libraryDir, md, coverImg); err != nil { + fmt.Println("Failed to cover image:", coverImg, err) + result.Move.Err = err + } + } + + os.Remove(albumPath) } fmt.Println("\n=== Import Complete ===") diff --git a/media.go b/media.go index e0274a6..555b8e7 100644 --- a/media.go +++ b/media.go @@ -57,17 +57,14 @@ func EmbedAlbumArtIntoFolder(albumDir string) error { return err } -// 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) - } +// 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) + if err != nil { + return fmt.Errorf("MusicBrainz release search failed: %w", err) } data, ext, err := fetchCoverArtArchiveFront(mbid) @@ -157,58 +154,6 @@ 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/metadata.go b/metadata.go index da146a8..af7734c 100644 --- a/metadata.go +++ b/metadata.go @@ -207,9 +207,7 @@ 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. In that case, quiet mode is skipped and newlines are -// piped to stdin so beets auto-accepts the pinned release regardless of -// confidence score. +// MusicBrainz release. func tagWithBeets(path, mbid string) error { fmt.Println("→ Tagging with beets:", path) @@ -221,23 +219,13 @@ func tagWithBeets(path, mbid string) error { logFile.Close() defer os.Remove(logPath) - args := []string{"import", "-C", "-l", logPath} + args := []string{"import", "-Cq", "-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 - } + args = append(args, "--search-id", mbid) + } + args = append(args, path) + if err := runCmd("beet", args...); err != nil { + return err } // Even on exit 0, beets may have skipped the album in quiet mode. diff --git a/monitor.go b/monitor.go index c09438b..2b842df 100644 --- a/monitor.go +++ b/monitor.go @@ -13,15 +13,13 @@ 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 - TrackCount int // expected number of audio tracks (0 = unknown, skip check) + MBID string + 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 } var ( @@ -30,38 +28,32 @@ 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, trackCount int, folder *albumFolder, entry *fetchEntry) { +// auto-import. If entry is nil a new fetchEntry is created, keyed by mbid, +// so the frontend can discover it via /discover/fetch/list. +func registerDownload(mbid, artist, album string, 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, - TrackCount: trackCount, + MBID: mbid, + Artist: artist, + Album: album, + Username: folder.Username, + Dir: folder.Dir, + Files: folder.Files, + Entry: entry, } if entry == nil { - e := newFetchEntry(id, artist, album) + e := newFetchEntry(mbid, artist, album) e.appendLog(fmt.Sprintf("Queued %d files from %s — waiting for download", len(folder.Files), folder.Username)) pd.Entry = e } pendingMu.Lock() - pendingDownloads[id] = pd + pendingDownloads[mbid] = pd pendingMu.Unlock() - 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) + log.Printf("[monitor] registered: %q by %s (mbid: %s, peer: %s, %d files)", + album, artist, mbid, folder.Username, len(folder.Files)) } // startMonitor launches a background goroutine that periodically checks whether @@ -136,7 +128,7 @@ func checkPendingDownloads() { // Remove from pending before starting import to avoid double-import. pendingMu.Lock() - delete(pendingDownloads, pd.ID) + delete(pendingDownloads, pd.MBID) pendingMu.Unlock() go importPendingRelease(pd, localDir) @@ -197,7 +189,7 @@ func importPendingRelease(pd *pendingDownload, localDir string) { entry := pd.Entry logf := func(msg string) { entry.appendLog("[import] " + msg) - log.Printf("[monitor/import %s] %s", pd.ID, msg) + log.Printf("[monitor/import %s] %s", pd.MBID, msg) } logf(fmt.Sprintf("Starting import from %s", localDir)) @@ -219,19 +211,11 @@ 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)) } - md, src, err := getAlbumMetadata(localDir, tracks[0], pd.BeetsMBID) + md, src, err := getAlbumMetadata(localDir, tracks[0], pd.MBID) if err != nil { entry.finish(fmt.Errorf("metadata failed: %w", err)) return @@ -249,28 +233,17 @@ func importPendingRelease(pd *pendingDownload, localDir string) { logf("ReplayGain applied") if _, err := FindCoverImage(localDir); err != nil { - if err := DownloadCoverArt(localDir, md, pd.BeetsMBID); err != nil { + if err := DownloadCoverArt(localDir, md); err != nil { logf(fmt.Sprintf("Cover art download warning: %v", err)) } } - 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 } logf("Cover art embedded") - targetDir := albumTargetDir(libraryDir, md) - if _, err := os.Stat(targetDir); err == nil { - logf(fmt.Sprintf("Album already exists in library, skipping move: %s", targetDir)) - entry.finish(nil) - return - } - var moveErr error for _, track := range tracks { if err := moveToLibrary(libraryDir, md, track); err != nil { diff --git a/music-import b/music-import new file mode 100755 index 0000000..553b9ee Binary files /dev/null and b/music-import differ diff --git a/slskd.go b/slskd.go index 70fcad9..35e9c1c 100644 --- a/slskd.go +++ b/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(60 * time.Second) + deadline := time.Now().Add(30 * time.Second) for { resp, err := slskdDo("GET", "/api/v0/searches/"+id, nil) if err != nil { @@ -384,12 +384,9 @@ 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). -// 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) { +func fetchRelease(artist, album, mbid string, logf func(string)) (*albumFolder, error) { 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("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) } - // 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) + best := bestAlbumFolder(folders) 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 e4e4104..64d822c 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,124 +12,101 @@ 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 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`; + 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 meta = [year, type].filter(Boolean).join(' \u00b7 '); return `