diff --git a/backend/app.go b/backend/app.go index 18e95d0..b650a03 100644 --- a/backend/app.go +++ b/backend/app.go @@ -8,6 +8,8 @@ import ( "errors" "fmt" "log/slog" + "net/http" + "path/filepath" wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" @@ -23,6 +25,7 @@ import ( "yellowjacket/backend/playlist" "yellowjacket/backend/profiling" "yellowjacket/backend/queue" + "yellowjacket/backend/system" "yellowjacket/backend/tagwriter" ) @@ -104,6 +107,17 @@ func NewYellowJacketApp( yjApp.assetHandler.RegisterHandler(coverart.PathPrefix, coverHandler) + // Register artist image handler for serving cached artist photos. + artistImgDir, err := system.GetUserDataDirPath() + if err == nil { + artistImgHandler := http.StripPrefix( + "/artist-images/", + http.FileServer(http.Dir(filepath.Join(artistImgDir, "artist-images"))), + ) + + yjApp.assetHandler.RegisterHandler("/artist-images/", artistImgHandler) + } + // create playlist service yjApp.playlist = playlist.NewService( yjApp.logger, yjApp.database, yjApp.appConfig, diff --git a/backend/database/database.go b/backend/database/database.go index 03b8350..0277c59 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -400,6 +400,15 @@ func runMigrations( } } + // Migration 16: artist_images table for multi-source artist photos. + if version < 16 { //nolint:mnd + if err := migration16ArtistImages( + ctx, db, logger, + ); err != nil { + return err + } + } + return nil } @@ -1771,6 +1780,59 @@ func migration15PersonalizationColumns( return nil } +// migration16ArtistImages creates the artist_images table for +// storing multiple artist photos from multiple sources, with +// thumbnail generation for the primary image. +func migration16ArtistImages( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info("applying migration 16: artist_images table") + + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS artist_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artist_mbid TEXT NOT NULL, + source TEXT NOT NULL, + source_url TEXT NOT NULL, + file_path TEXT NOT NULL, + is_primary INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + width INTEGER, + height INTEGER, + file_size INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + return fmt.Errorf("migration 16: create artist_images: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_artist_images_mbid + ON artist_images(artist_mbid) + `); err != nil { + return fmt.Errorf("migration 16: create mbid index: %w", err) + } + + if _, err := db.ExecContext(ctx, ` + CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source + ON artist_images(artist_mbid, source, source_url) + `); err != nil { + return fmt.Errorf("migration 16: create source index: %w", err) + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 16", + ); err != nil { + return fmt.Errorf("could not set user_version to 16: %w", err) + } + + logger.Info("migration 16 complete") + + return nil +} + // readLibraryDirFromTOML reads the TOML config file and returns // the Library.DirectoryPath value, or "" if not configured. func readLibraryDirFromTOML(logger *slog.Logger) string { diff --git a/backend/explore/artistimage.go b/backend/explore/artistimage.go index b2456d2..72917e7 100644 --- a/backend/explore/artistimage.go +++ b/backend/explore/artistimage.go @@ -2,20 +2,24 @@ package explore import ( "context" - "crypto/md5" //nolint:gosec // MD5 used for Wikimedia URL hashing, not security + "crypto/md5" //nolint:gosec // MD5 for Wikimedia URL hashing "encoding/base64" "encoding/json" "errors" "fmt" + "image" + "image/jpeg" + _ "image/png" // register PNG decoder "io" "log/slog" "net/http" "os" "path/filepath" "strings" - "sync" "time" + "golang.org/x/image/draw" + "yellowjacket/backend/database" "yellowjacket/backend/system" ) @@ -26,32 +30,41 @@ var ErrArtistImage = errors.New("artist image fetch failed") const ( wikimediaThumbBase = "https://upload.wikimedia.org/wikipedia/commons/thumb" wikidataAPIBase = "https://www.wikidata.org/w/api.php" - artistImageSize = 250 + wikipediaAPIBase = "https://en.wikipedia.org/w/api.php" artistImageTimeout = 10 * time.Second artistImageCacheTTL = 30 * 24 * time.Hour - artistImageDir = "artist-image-cache" - artistImageMaxBytes = 2 * 1024 * 1024 // 2 MB max per image + artistImageBaseDir = "artist-images" + artistImageMaxBytes = 2 * 1024 * 1024 + artistImageMaxSize = 500 // max dimension for stored full-res images + maxImagesPerArtist = 10 ) -// ArtistImageProvider resolves artist MBIDs to images. It checks -// three sources in order: -// 1. Local disk cache (instant, from previous fetch) -// 2. MB url-rels → Wikimedia Commons thumb URL → fetch + cache -// 3. Wikidata P18 → Wikimedia Commons thumb URL → fetch + cache -// -// Returns base64 data URLs for display in . +// artistImageTier defines a thumbnail size variant. +type artistImageTier struct { + Suffix string + MaxSize int + Quality int +} + +var artistImageTiers = []artistImageTier{ + {Suffix: "_sm", MaxSize: 100, Quality: 75}, + {Suffix: "_md", MaxSize: 200, Quality: 80}, + {Suffix: "_lg", MaxSize: 400, Quality: 85}, +} + +// ArtistImageProvider resolves, fetches, and caches artist images +// from multiple sources. Stores up to 10 images per artist with +// sm/md/lg thumbnails for the primary image. type ArtistImageProvider struct { db *database.DB cache *Cache mbLimiter *RateLimiter client *http.Client logger *slog.Logger - imageDir string - mu sync.Mutex // serializes disk writes + baseDir string } -// NewArtistImageProvider creates a provider that resolves and caches -// artist images. +// NewArtistImageProvider creates a multi-source artist image provider. func NewArtistImageProvider( db *database.DB, cache *Cache, @@ -62,7 +75,7 @@ func NewArtistImageProvider( dataDir, err := system.GetUserDataDirPath() if err == nil { - dir = filepath.Join(dataDir, artistImageDir) + dir = filepath.Join(dataDir, artistImageBaseDir) _ = os.MkdirAll(dir, 0o755) } @@ -72,89 +85,125 @@ func NewArtistImageProvider( mbLimiter: mbLimiter, client: &http.Client{Timeout: artistImageTimeout}, logger: logger, - imageDir: dir, + baseDir: dir, } } -// GetCachedImage returns a base64 data URL from the disk cache -// only — no network fetches. Returns "" if not cached. -func (p *ArtistImageProvider) GetCachedImage(artistMBID string) string { - if artistMBID == "" || p.imageDir == "" { - return "" - } - - return p.readDiskCache(artistMBID) -} - -// GetArtistImage returns a base64 data URL for the artist's photo. -// Checks disk cache first, then resolves via MB/Wikidata and fetches -// the image from Wikimedia Commons. Returns "" if no image. -func (p *ArtistImageProvider) GetArtistImage(artistMBID string) string { - if artistMBID == "" || p.imageDir == "" { - return "" - } - - // Source 1: disk cache (instant). - if dataURL := p.readDiskCache(artistMBID); dataURL != "" { - return dataURL - } - - // Check if we already know there's no image (cached miss marker). - if p.isDiskCacheMiss(artistMBID) { - return "" - } - - // Source 2+3: resolve URL then fetch image. - imageURL := p.resolveURL(artistMBID) - if imageURL == "" { - p.writeDiskCache(artistMBID, nil) // miss marker - - return "" - } - - // Fetch the actual image bytes. - data, err := p.fetchImageBytes(imageURL) - if err != nil || len(data) == 0 { - p.writeDiskCache(artistMBID, nil) - - return "" - } - - p.writeDiskCache(artistMBID, data) - - return toDataURL(data, artistMBID) -} - -// resolveURL finds the Wikimedia Commons thumbnail URL for an -// artist via MB url-rels and Wikidata. The URL itself (not image -// bytes) is cached in explore_cache for 30 days. -func (p *ArtistImageProvider) resolveURL(artistMBID string) string { - cacheKey := "artist-image-url:" + artistMBID - - if data, ok := p.cache.Get(cacheKey); ok { - return string(data) - } - - rels := p.fetchMBRels(artistMBID) - if rels == nil { - p.cache.Set(cacheKey, []byte(""), artistImageCacheTTL, artistMBID, "artist") - - return "" - } - - imageURL := p.fromDirectImageRel(rels) - - if imageURL == "" { - imageURL = p.fromWikidataRel(rels) - } - - p.cache.Set(cacheKey, []byte(imageURL), artistImageCacheTTL, artistMBID, "artist") - - return imageURL -} - // --------------------------------------------------------------------------- -// MB url-rels +// Public API +// --------------------------------------------------------------------------- + +// GetArtistImage returns the primary image as a base64 data URL. +// Resolves from all sources if not yet cached. +func (p *ArtistImageProvider) GetArtistImage(artistMBID string) string { + if artistMBID == "" || p.baseDir == "" { + return "" + } + + // Check for existing primary image on disk. + primaryPath := p.primaryPath(artistMBID) + if data := readFileData(primaryPath); data != "" { + return data + } + + // Check if we already know there's no image. + if p.isMiss(artistMBID) { + return "" + } + + // Resolve from all sources and select primary. + p.resolveAllSources(artistMBID) + + // Try again after resolution. + if data := readFileData(primaryPath); data != "" { + return data + } + + // Mark as miss. + p.writeMiss(artistMBID) + + return "" +} + +// GetCachedImage returns the primary image from disk cache only. +// No network fetches. +func (p *ArtistImageProvider) GetCachedImage(artistMBID string) string { + if artistMBID == "" || p.baseDir == "" { + return "" + } + + return readFileData(p.primaryPath(artistMBID)) +} + +// GetImageURLs returns the asset-handler URLs for the primary image +// at all size tiers. Returns empty strings if no image. +func (p *ArtistImageProvider) GetImageURLs(artistMBID string) (string, string, string, string) { + if artistMBID == "" || p.baseDir == "" { + return "", "", "", "" + } + + dir := p.artistDir(artistMBID) + prefix := "/artist-images/" + artistMBID[:2] + "/" + artistMBID + "/" + + if _, err := os.Stat(filepath.Join(dir, "primary.jpg")); err != nil { + return "", "", "", "" + } + + var small, medium, large string + + full := prefix + "primary.jpg" + + for _, tier := range artistImageTiers { + path := filepath.Join(dir, "primary"+tier.Suffix+".jpg") + if _, err := os.Stat(path); err == nil { + url := prefix + "primary" + tier.Suffix + ".jpg" + + switch tier.Suffix { + case "_sm": + small = url + case "_md": + medium = url + case "_lg": + large = url + } + } + } + + return small, medium, large, full +} + +// GetAliases returns artist aliases from cached MB rels. +func (p *ArtistImageProvider) GetAliases(artistMBID string) string { + cacheKey := "mb:artist-rels:" + artistMBID + + data, ok := p.cache.Get(cacheKey) + if !ok { + return "" + } + + var envelope struct { + Aliases []struct { + Name string `json:"name"` + } `json:"aliases"` + } + + if err := json.Unmarshal(data, &envelope); err != nil || len(envelope.Aliases) == 0 { + return "" + } + + names := make([]string, 0, len(envelope.Aliases)) + + for _, a := range envelope.Aliases { + if a.Name != "" { + names = append(names, a.Name) + } + } + + return strings.Join(names, " ") +} + +// --------------------------------------------------------------------------- +// Source resolution // --------------------------------------------------------------------------- type mbRelation struct { @@ -164,6 +213,195 @@ type mbRelation struct { } `json:"url"` } +func (p *ArtistImageProvider) resolveAllSources(artistMBID string) { + rels := p.fetchMBRels(artistMBID) + + var urls []struct { + source string + url string + } + + // Source 1: MB direct image relations (Wikimedia Commons). + for _, rel := range rels { + if rel.Type != "image" { + continue + } + + resource := rel.URL.Resource + + if idx := strings.LastIndex(resource, "File:"); idx >= 0 { + filename := resource[idx+5:] + thumbURL := wikimediaThumbURL(filename) + + if thumbURL != "" { + urls = append(urls, struct { + source string + url string + }{"wikimedia", thumbURL}) + } + } + } + + // Source 2: Wikidata P18. + qid := p.getWikidataQID(rels) + if qid != "" { + if thumbURL := p.fetchWikidataP18(qid); thumbURL != "" { + // Avoid duplicates with source 1. + dup := false + + for _, u := range urls { + if u.url == thumbURL { + dup = true + + break + } + } + + if !dup { + urls = append(urls, struct { + source string + url string + }{"wikidata", thumbURL}) + } + } + + // Source 3: Wikipedia lead image. + if leadURL := p.fetchWikipediaLeadImage(qid); leadURL != "" { + dup := false + + for _, u := range urls { + if u.url == leadURL { + dup = true + + break + } + } + + if !dup { + urls = append(urls, struct { + source string + url string + }{"wikipedia", leadURL}) + } + } + } + + if len(urls) == 0 { + return + } + + // Cap at maxImagesPerArtist. + if len(urls) > maxImagesPerArtist { + urls = urls[:maxImagesPerArtist] + } + + // Fetch and store each image. + dir := p.artistDir(artistMBID) + _ = os.MkdirAll(dir, 0o755) + + for i, u := range urls { + imgData, err := p.fetchImageBytes(u.url) + if err != nil || len(imgData) == 0 { + continue + } + + filename := fmt.Sprintf("%s_%d.jpg", u.source, i) + path := filepath.Join(dir, filename) + _ = os.WriteFile(path, imgData, 0o644) + + // Store in DB. + isPrimary := 0 + if i == 0 { + isPrimary = 1 + } + + _, _ = p.db.ExecContext(` + INSERT OR REPLACE INTO artist_images + (artist_mbid, source, source_url, file_path, is_primary, sort_order, file_size) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, artistMBID, u.source, u.url, path, isPrimary, i, len(imgData)) + + // Generate thumbnails for the primary image. + if i == 0 { + p.setPrimary(artistMBID, dir, imgData) + } + } +} + +// setPrimary copies image data to primary.jpg and generates thumbnails. +func (p *ArtistImageProvider) setPrimary(artistMBID, dir string, imgData []byte) { + primaryPath := filepath.Join(dir, "primary.jpg") + _ = os.WriteFile(primaryPath, imgData, 0o644) + + // Decode and generate thumbnails. + img, _, err := image.Decode(strings.NewReader(string(imgData))) + if err != nil { + // Try as bytes reader. + reader := strings.NewReader(string(imgData)) + + img, _, err = image.Decode(reader) + if err != nil { + p.logger.Debug("artist image: could not decode for thumbnails", + "mbid", artistMBID, "error", err) + + return + } + } + + for _, tier := range artistImageTiers { + thumbPath := filepath.Join(dir, "primary"+tier.Suffix+".jpg") + p.generateThumbnail(img, thumbPath, tier.MaxSize, tier.Quality) + } +} + +func (p *ArtistImageProvider) generateThumbnail( + src image.Image, path string, maxSize, quality int, +) { + bounds := src.Bounds() + w := bounds.Dx() + h := bounds.Dy() + + if w <= maxSize && h <= maxSize { + // Image already small enough — just encode as JPEG. + f, err := os.Create(path) + if err != nil { + return + } + + defer func() { _ = f.Close() }() + + _ = jpeg.Encode(f, src, &jpeg.Options{Quality: quality}) + + return + } + + // Scale down maintaining aspect ratio. + var newW, newH int + if w > h { + newW = maxSize + newH = maxSize * h / w + } else { + newH = maxSize + newW = maxSize * w / h + } + + dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) + draw.BiLinear.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil) + + f, err := os.Create(path) + if err != nil { + return + } + + defer func() { _ = f.Close() }() + + _ = jpeg.Encode(f, dst, &jpeg.Options{Quality: quality}) +} + +// --------------------------------------------------------------------------- +// MB rels + Wikidata + Wikipedia +// --------------------------------------------------------------------------- + func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation { cacheKey := "mb:artist-rels:" + artistMBID @@ -188,11 +426,6 @@ func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation { body, err := p.fetchURL(url) if err != nil { - p.logger.Debug("artist image: MB rels fetch failed", - "mbid", artistMBID, - "error", err, - ) - return nil } @@ -209,48 +442,19 @@ func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation { return envelope.Relations } -// --------------------------------------------------------------------------- -// Source 1: direct image relation -// --------------------------------------------------------------------------- - -func (p *ArtistImageProvider) fromDirectImageRel(rels []mbRelation) string { +func (p *ArtistImageProvider) getWikidataQID(rels []mbRelation) string { for _, rel := range rels { - if rel.Type != "image" { - continue - } + if rel.Type == "wikidata" { + parts := strings.Split(rel.URL.Resource, "/") - resource := rel.URL.Resource - - if idx := strings.LastIndex(resource, "File:"); idx >= 0 { - filename := resource[idx+5:] - - return wikimediaThumbURL(filename) + return parts[len(parts)-1] } } return "" } -// --------------------------------------------------------------------------- -// Source 2: Wikidata P18 -// --------------------------------------------------------------------------- - -func (p *ArtistImageProvider) fromWikidataRel(rels []mbRelation) string { - qid := "" - - for _, rel := range rels { - if rel.Type == "wikidata" { - parts := strings.Split(rel.URL.Resource, "/") - qid = parts[len(parts)-1] - - break - } - } - - if qid == "" { - return "" - } - +func (p *ArtistImageProvider) fetchWikidataP18(qid string) string { cacheKey := "wikidata-p18:" + qid if data, ok := p.cache.Get(cacheKey); ok { @@ -291,46 +495,121 @@ func (p *ArtistImageProvider) fromWikidataRel(rels []mbRelation) string { return thumbURL } -// --------------------------------------------------------------------------- -// Disk cache -// --------------------------------------------------------------------------- +func (p *ArtistImageProvider) fetchWikipediaLeadImage(qid string) string { + cacheKey := "wikipedia-lead:" + qid -func (p *ArtistImageProvider) diskCachePath(mbid string) string { - return filepath.Join(p.imageDir, mbid+".jpg") -} + if data, ok := p.cache.Get(cacheKey); ok { + return string(data) + } -func (p *ArtistImageProvider) readDiskCache(mbid string) string { - data, err := os.ReadFile(p.diskCachePath(mbid)) + // Get the English Wikipedia article title from Wikidata sitelinks. + titleURL := fmt.Sprintf( + "%s?action=wbgetentities&ids=%s&props=sitelinks&sitefilter=enwiki&format=json", + wikidataAPIBase, qid, + ) + + titleBody, err := p.fetchURL(titleURL) if err != nil { return "" } - if len(data) == 0 { - return "" // miss marker + var sitelinks struct { + Entities map[string]struct { + Sitelinks map[string]struct { + Title string `json:"title"` + } `json:"sitelinks"` + } `json:"entities"` } - return toDataURL(data, mbid) -} - -func (p *ArtistImageProvider) isDiskCacheMiss(mbid string) bool { - info, err := os.Stat(p.diskCachePath(mbid)) - - return err == nil && info.Size() == 0 -} - -func (p *ArtistImageProvider) writeDiskCache(mbid string, data []byte) { - p.mu.Lock() - defer p.mu.Unlock() - - if data == nil { - data = []byte{} // miss marker + if err := json.Unmarshal(titleBody, &sitelinks); err != nil { + return "" } - _ = os.WriteFile(p.diskCachePath(mbid), data, 0o644) + entity, ok := sitelinks.Entities[qid] + if !ok { + return "" + } + + enwiki, ok := entity.Sitelinks["enwiki"] + if !ok || enwiki.Title == "" { + p.cache.Set(cacheKey, []byte(""), artistImageCacheTTL, "", "") + + return "" + } + + // Fetch the lead image from Wikipedia. + imgURL := fmt.Sprintf( + "%s?action=query&titles=%s&prop=pageimages&format=json&pithumbsize=%d", + wikipediaAPIBase, + strings.ReplaceAll(enwiki.Title, " ", "_"), + artistImageMaxSize, + ) + + imgBody, err := p.fetchURL(imgURL) + if err != nil { + return "" + } + + var wp struct { + Query struct { + Pages map[string]struct { + Thumbnail struct { + Source string `json:"source"` + } `json:"thumbnail"` + } `json:"pages"` + } `json:"query"` + } + + if err := json.Unmarshal(imgBody, &wp); err != nil { + return "" + } + + leadURL := "" + + for _, page := range wp.Query.Pages { + if page.Thumbnail.Source != "" { + leadURL = page.Thumbnail.Source + + break + } + } + + p.cache.Set(cacheKey, []byte(leadURL), artistImageCacheTTL, "", "") + + return leadURL } // --------------------------------------------------------------------------- -// Image fetching +// Disk paths +// --------------------------------------------------------------------------- + +func (p *ArtistImageProvider) artistDir(mbid string) string { + if len(mbid) < 2 { + return filepath.Join(p.baseDir, "xx", mbid) + } + + return filepath.Join(p.baseDir, mbid[:2], mbid) +} + +func (p *ArtistImageProvider) primaryPath(mbid string) string { + return filepath.Join(p.artistDir(mbid), "primary.jpg") +} + +func (p *ArtistImageProvider) isMiss(mbid string) bool { + missPath := filepath.Join(p.artistDir(mbid), ".miss") + _, err := os.Stat(missPath) + + return err == nil +} + +func (p *ArtistImageProvider) writeMiss(mbid string) { + dir := p.artistDir(mbid) + _ = os.MkdirAll(dir, 0o755) + _ = os.WriteFile(filepath.Join(dir, ".miss"), []byte{}, 0o644) +} + +// --------------------------------------------------------------------------- +// HTTP helpers // --------------------------------------------------------------------------- func (p *ArtistImageProvider) fetchImageBytes(imageURL string) ([]byte, error) { @@ -358,58 +637,6 @@ func (p *ArtistImageProvider) fetchImageBytes(imageURL string) ([]byte, error) { return io.ReadAll(io.LimitReader(resp.Body, artistImageMaxBytes)) } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -func wikimediaThumbURL(filename string) string { - if filename == "" { - return "" - } - - filename = strings.ReplaceAll(filename, " ", "_") - - hash := fmt.Sprintf("%x", md5.Sum([]byte(filename))) //nolint:gosec - h1 := string(hash[0]) - h2 := hash[:2] - - return fmt.Sprintf("%s/%s/%s/%s/%dpx-%s", - wikimediaThumbBase, h1, h2, filename, artistImageSize, filename, - ) -} - -// GetAliases returns the artist's aliases as a space-separated -// string, extracted from the cached MB rels response. Returns "" -// if no aliases are cached. -func (p *ArtistImageProvider) GetAliases(artistMBID string) string { - cacheKey := "mb:artist-rels:" + artistMBID - - data, ok := p.cache.Get(cacheKey) - if !ok { - return "" - } - - var envelope struct { - Aliases []struct { - Name string `json:"name"` - } `json:"aliases"` - } - - if err := json.Unmarshal(data, &envelope); err != nil || len(envelope.Aliases) == 0 { - return "" - } - - names := make([]string, 0, len(envelope.Aliases)) - - for _, a := range envelope.Aliases { - if a.Name != "" { - names = append(names, a.Name) - } - } - - return strings.Join(names, " ") -} - func (p *ArtistImageProvider) fetchURL(url string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), artistImageTimeout) defer cancel() @@ -435,7 +662,32 @@ func (p *ArtistImageProvider) fetchURL(url string) ([]byte, error) { return io.ReadAll(resp.Body) } -func toDataURL(data []byte, _ string) string { +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +func wikimediaThumbURL(filename string) string { + if filename == "" { + return "" + } + + filename = strings.ReplaceAll(filename, " ", "_") + + hash := fmt.Sprintf("%x", md5.Sum([]byte(filename))) //nolint:gosec + h1 := string(hash[0]) + h2 := hash[:2] + + return fmt.Sprintf("%s/%s/%s/%s/%dpx-%s", + wikimediaThumbBase, h1, h2, filename, artistImageMaxSize, filename, + ) +} + +func readFileData(path string) string { + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return "" + } + mime := "image/jpeg" if len(data) > 1 && data[0] == 0x89 && data[1] == 0x50 { mime = "image/png" diff --git a/backend/library/query.go b/backend/library/query.go index 0ca40a6..eb68924 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -4,12 +4,15 @@ import ( "database/sql" "errors" "fmt" + "os" + "path/filepath" "strconv" "strings" "time" "yellowjacket/backend/coverart" "yellowjacket/backend/database/sql/sqlcgen" + "yellowjacket/backend/system" ) // Sentinel errors for library queries. @@ -142,8 +145,11 @@ func (l *Library) GetTrackMBIDs(filePath string) TrackMBIDs { // Artist represents an artist in the library. type Artist struct { - ID int64 - Name string + ID int64 + Name string + ImageSmall string + ImageMedium string + ImageLarge string } // Album represents an album for the cover grid display. @@ -366,9 +372,69 @@ func (l *Library) GetAllArtists() ([]Artist, error) { }) } + // Resolve artist image URLs from the disk cache. + l.resolveArtistImages(artists) + return artists, nil } +// resolveArtistImages populates ImageSmall/Medium/Large for artists +// that have cached images on disk. Does a bulk MBID lookup from the +// artists table, then checks the artist-images directory for each. +func (l *Library) resolveArtistImages(artists []Artist) { + if len(artists) == 0 { + return + } + + dataDir, err := system.GetUserDataDirPath() + if err != nil { + return + } + + baseDir := filepath.Join(dataDir, "artist-images") + + // Bulk load name→mbid from the artists table. + rows, err := l.db.QueryContext( + "SELECT name, mbid FROM artists WHERE mbid IS NOT NULL AND mbid != ''", + ) + if err != nil { + return + } + + defer func() { _ = rows.Close() }() + + mbidMap := make(map[string]string) + + for rows.Next() { + var name, mbid string + if err := rows.Scan(&name, &mbid); err == nil { + mbidMap[name] = mbid + } + } + + for i := range artists { + mbid, ok := mbidMap[artists[i].Name] + if !ok || len(mbid) < 2 { + continue + } + + dir := filepath.Join(baseDir, mbid[:2], mbid) + prefix := "/artist-images/" + mbid[:2] + "/" + mbid + "/" + + if _, err := os.Stat(filepath.Join(dir, "primary_sm.jpg")); err == nil { + artists[i].ImageSmall = prefix + "primary_sm.jpg" + } + + if _, err := os.Stat(filepath.Join(dir, "primary_md.jpg")); err == nil { + artists[i].ImageMedium = prefix + "primary_md.jpg" + } + + if _, err := os.Stat(filepath.Join(dir, "primary_lg.jpg")); err == nil { + artists[i].ImageLarge = prefix + "primary_lg.jpg" + } + } +} + // GetAlbumsByArtist returns all albums where the given artist is the album artist. func (l *Library) GetAlbumsByArtist( artistID int64, @@ -646,6 +712,8 @@ func (l *Library) GetAllArtistsByLibrary( }) } + l.resolveArtistImages(artists) + return artists, nil } diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index 0c7f0da..ebf03fd 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -16,7 +16,6 @@ import { GetAlbumsByArtistByLibrary, GetAlbumTracksByLibrary, } from '@go/library/Library'; -import { GetArtistImageURL, GetArtistMBID, GetArtistImages } from '@go/explore/Service'; import { library } from '@go/models'; import { LibraryController } from '@store/controllers/library-controller'; import { SearchController } from '@store/controllers/search-controller'; @@ -177,8 +176,6 @@ export class ArtistsView private cachedGridEntries: ArtistEntry[] = []; private prevFilterArtists: library.Artist[] = []; private prevFilterTerm = ''; - private artistImageCache = new Map(); - private artistImageLoading = new Set(); /** * Recompute the filtered-artists and grid-entries @@ -439,8 +436,6 @@ export class ArtistsView ) { this.lastArtistsRef = cached; this.loadArtists(); - this.imagesBatchLoaded = false; - void this.loadArtistImagesBatch(); } } @@ -456,9 +451,6 @@ export class ArtistsView await this.libraryCtrl.getArtists(); this.artists = artists ?? []; - - // Batch load artist images after artists are loaded. - void this.loadArtistImagesBatch(); } catch (error) { console.error( 'Error loading artists:', @@ -990,91 +982,32 @@ export class ArtistsView * Helpers * ================================================================ */ - private renderArtistAvatar(name: string) { - const imageURL = this.artistImageCache.get(name); + private renderArtistAvatar(artist: library.Artist) { + const needed = (this.imageSize ?? 176) * window.devicePixelRatio; + let imageURL = ''; + + if (needed <= 100) { + imageURL = artist.ImageSmall || artist.ImageMedium || artist.ImageLarge || ''; + } else if (needed <= 200) { + imageURL = artist.ImageMedium || artist.ImageLarge || ''; + } else { + imageURL = artist.ImageLarge || ''; + } if (imageURL) { return html`${name}`; } return html` - ${this.getArtistInitial(name)} + ${this.getArtistInitial(artist.Name)} `; } - private imagesBatchLoaded = false; - - /** - * Batch load all artist images in one Wails call. - * Only returns already-cached images (from the disk cache - * populated by the index build). Uncached artists fall back - * to the initial letter. - */ - private async loadArtistImagesBatch() { - if (this.imagesBatchLoaded) return; - - this.imagesBatchLoaded = true; - - const artists = this.libraryCtrl.cachedArtists; - - if (!artists || artists.length === 0) return; - - const names = artists.map((a) => a.Name); - - try { - const images = await GetArtistImages(names); - - if (images && Object.keys(images).length > 0) { - for (const [name, url] of Object.entries(images)) { - if (url) { - this.artistImageCache.set(name, url); - } - } - - this.requestUpdate(); - } - } catch { - // Non-critical. - } - } - - /** - * Load artist image for a single artist on-demand (fallback - * for artists not resolved by the batch call). - */ - private loadArtistImage(name: string) { - if (this.artistImageCache.has(name) || this.artistImageLoading.has(name)) { - return; - } - - this.artistImageLoading.add(name); - - GetArtistMBID(name) - .then((mbid) => { - if (!mbid) return Promise.resolve(''); - - return GetArtistImageURL(mbid); - }) - .then((url) => { - if (url) { - this.artistImageCache.set(name, url); - this.requestUpdate(); - } else { - this.artistImageCache.set(name, ''); - } - }) - .catch(() => { - this.artistImageCache.set(name, ''); - }) - .finally(() => { - this.artistImageLoading.delete(name); - }); - } - private getArtistInitial( name: string, ): string { @@ -1147,7 +1080,7 @@ export class ArtistsView }} >
- ${this.renderArtistAvatar(artist.Name)} + ${this.renderArtistAvatar(artist)}
; export function GetArtistImageURL(arg1:string):Promise; +export function GetArtistImages(arg1:Array):Promise>; + export function GetArtistMBID(arg1:string):Promise; export function GetThumbnail(arg1:string,arg2:string,arg3:string):Promise; @@ -44,5 +46,3 @@ export function StartIndexBuild():Promise; export function StopIndexBuild():Promise; export function TopRecordingsForArtist(arg1:string):Promise>; - -export function GetArtistImages(arg1:string[]):Promise>; diff --git a/frontend/wailsjs/go/explore/Service.js b/frontend/wailsjs/go/explore/Service.js index 5905016..d8ca373 100755 --- a/frontend/wailsjs/go/explore/Service.js +++ b/frontend/wailsjs/go/explore/Service.js @@ -26,6 +26,10 @@ export function GetArtistImageURL(arg1) { return window['go']['explore']['Service']['GetArtistImageURL'](arg1); } +export function GetArtistImages(arg1) { + return window['go']['explore']['Service']['GetArtistImages'](arg1); +} + export function GetArtistMBID(arg1) { return window['go']['explore']['Service']['GetArtistMBID'](arg1); } @@ -85,7 +89,3 @@ export function StopIndexBuild() { export function TopRecordingsForArtist(arg1) { return window['go']['explore']['Service']['TopRecordingsForArtist'](arg1); } - -export function GetArtistImages(arg1) { - return window['go']['explore']['Service']['GetArtistImages'](arg1); -} diff --git a/frontend/wailsjs/go/library/Library.d.ts b/frontend/wailsjs/go/library/Library.d.ts index f5ac6d6..599f83a 100755 --- a/frontend/wailsjs/go/library/Library.d.ts +++ b/frontend/wailsjs/go/library/Library.d.ts @@ -46,6 +46,8 @@ export function GetRemovalImpact(arg1:number):Promise; export function GetScanQueueLength():Promise; +export function GetTrackMBIDs(arg1:string):Promise; + export function GetTracksByGenre(arg1:string):Promise>; export function GetTracksByGenreByLibrary(arg1:string,arg2:number):Promise>; @@ -83,11 +85,3 @@ export function SetRescanHooks(arg1:library.RescanHooks):Promise; export function SetScanHooks(arg1:library.ScanHooks):Promise; export function SoftScanAllLibraries():Promise; - -export interface TrackMBIDs { - recordingMbid: string; - releaseGroupMbid: string; - artistMbid: string; -} - -export function GetTrackMBIDs(arg1:string):Promise; diff --git a/frontend/wailsjs/go/library/Library.js b/frontend/wailsjs/go/library/Library.js index b9f0dd2..a7c0f1d 100755 --- a/frontend/wailsjs/go/library/Library.js +++ b/frontend/wailsjs/go/library/Library.js @@ -86,6 +86,10 @@ export function GetScanQueueLength() { return window['go']['library']['Library']['GetScanQueueLength'](); } +export function GetTrackMBIDs(arg1) { + return window['go']['library']['Library']['GetTrackMBIDs'](arg1); +} + export function GetTracksByGenre(arg1) { return window['go']['library']['Library']['GetTracksByGenre'](arg1); } @@ -161,7 +165,3 @@ export function SetScanHooks(arg1) { export function SoftScanAllLibraries() { return window['go']['library']['Library']['SoftScanAllLibraries'](); } - -export function GetTrackMBIDs(arg1) { - return window['go']['library']['Library']['GetTrackMBIDs'](arg1); -} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 8b5deaa..c6a8f4f 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -247,6 +247,9 @@ export namespace library { export class Artist { ID: number; Name: string; + ImageSmall: string; + ImageMedium: string; + ImageLarge: string; static createFrom(source: any = {}) { return new Artist(source); @@ -256,6 +259,9 @@ export namespace library { if ('string' === typeof source) source = JSON.parse(source); this.ID = source["ID"]; this.Name = source["Name"]; + this.ImageSmall = source["ImageSmall"]; + this.ImageMedium = source["ImageMedium"]; + this.ImageLarge = source["ImageLarge"]; } } export class GenreWithCount { @@ -486,6 +492,9 @@ export namespace library { FileSize: number; PlayCount: number; LastPlayed: string; + RecordingMBID: string; + ArtistMBID: string; + ReleaseGroupMBID: string; static createFrom(source: any = {}) { return new Track(source); @@ -511,6 +520,25 @@ export namespace library { this.FileSize = source["FileSize"]; this.PlayCount = source["PlayCount"]; this.LastPlayed = source["LastPlayed"]; + this.RecordingMBID = source["RecordingMBID"]; + this.ArtistMBID = source["ArtistMBID"]; + this.ReleaseGroupMBID = source["ReleaseGroupMBID"]; + } + } + export class TrackMBIDs { + recordingMbid: string; + releaseGroupMbid: string; + artistMbid: string; + + static createFrom(source: any = {}) { + return new TrackMBIDs(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.recordingMbid = source["recordingMbid"]; + this.releaseGroupMbid = source["releaseGroupMbid"]; + this.artistMbid = source["artistMbid"]; } }