feat: multi-source artist images with thumbnails + grid integration

Complete rewrite of the artist image pipeline:

STORAGE:
- Migration 16: artist_images table tracking source, URL, path,
  primary flag, dimensions per image (up to 10 per artist)
- Directory structure: artist-images/{mbid[:2]}/{mbid}/ with
  primary.jpg + primary_sm.jpg/_md.jpg/_lg.jpg thumbnails
- Miss marker (.miss file) prevents re-fetching artists with no image

SOURCES (priority order):
1. MusicBrainz direct image relations (Wikimedia Commons)
2. Wikidata P18 property (Wikimedia Commons)
3. Wikipedia lead image (NEW — via Wikidata sitelinks → Wikipedia API)

Each source is checked, deduplicated, and the first available
image becomes the primary with sm/md/lg thumbnail generation
(100px/200px/400px, matching cover art tier sizes).

ASSET SERVING:
- /artist-images/ path registered with Wails asset handler
- Serves files via http.FileServer from the artist-images directory
- Same pattern as /covers/ for cover art

ARTIST MODEL:
- Artist struct gains ImageSmall/ImageMedium/ImageLarge fields
- resolveArtistImages does bulk MBID lookup → disk stat for each
- Populated in GetAllArtists and GetAllArtistsByLibrary

GRID VIEW:
- artists-view uses model URLs directly (no more base64 data URLs)
- Size selection based on imageSize * devicePixelRatio (like cover-grid)
- Removed batch GetArtistImages call and in-memory cache — no longer needed
This commit is contained in:
2026-03-29 08:33:32 -04:00
parent 7ec9785463
commit 19a803d1fc
10 changed files with 668 additions and 317 deletions
@@ -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<string, string>();
private artistImageLoading = new Set<string>();
/**
* 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`<img
class="avatar-image"
src="${imageURL}"
alt="${name}"
alt="${artist.Name}"
loading="lazy"
/>`;
}
return html`<span class="avatar-placeholder">
${this.getArtistInitial(name)}
${this.getArtistInitial(artist.Name)}
</span>`;
}
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
}}
>
<div class="avatar-container">
${this.renderArtistAvatar(artist.Name)}
${this.renderArtistAvatar(artist)}
</div>
<div
class="artist-name"