Navidrome uses a pluggable multi-agent system where external providers are tried in priority order (configured via Agents setting). First one to return valid data wins.
| Agent | Auth Required | Provides |
|---|---|---|
| Last.fm | API key + secret | Bio (multi-lang), images (OG scrape), similar artists, top songs |
| Deezer | None (public API) | Bio, images (4 sizes), similar artists, top songs |
| ListenBrainz | None | Similar artists, top songs, track similarity |
| Local | N/A | Top songs from library only (always-on fallback) |
| WASM Plugins | Varies | Any of the 11 agent interfaces |
Config: Agents = "lastfm,deezer" — comma-separated, order = priority.
Request: GET /img/{jwt-encoded-artwork-id}
│
▼
┌─ Image Cache Hit? ─── Yes ──▶ Serve (10-year cache header)
│ │
│ No
│ │
▼ ▼
ArtistArtPriority (configurable order):
┌──────────────────────────────────────────┐
│ 1. uploaded-image ← User-uploaded file │
│ 2. external ← Agent URLs (DB) │
│ 3. image-folder ← ArtistImageFolder/ │
│ (matches MBID.jpg or "Artist Name.jpg")│
│ 4. album/artist.* ← Album art folders │
│ 5. folder.* ← Artist folder + │
│ up to 2 parent dirs │
└──────────────────────────────────────────┘
│
▼
Cache result → Serve
Key config: ArtistArtPriority controls the source order. Default includes uploaded image first.
UI mount (ArtistShow.jsx)
│
▼
subsonic.getArtistInfo(id)
│
▼
Provider.UpdateArtistInfo()
│
├── Cache valid (< 24h)? → Return cached data immediately
│
├── Cache expired? → Return cached + enqueue background refresh
│
└── Never fetched? → Synchronous fetch from agents:
│
▼
GetArtistMBID() → parallel:
├─ GetArtistBiography() (HTML, sanitized)
├─ GetArtistImages() (small/medium/large URLs)
├─ GetArtistURL()
└─ GetSimilarArtists()
│
▼
Store in DB → Return to UI
Bio is rendered via SafeHTML (DOMPurify sanitized) in a collapsible section — 4.5em on desktop, 1.5em on mobile.
There's a full upload/delete system already built:
Backend (server/nativeapi/artists.go):
POST /api/artist/{id}/image— upload (max 10MB, any image/*)DELETE /api/artist/{id}/image— remove uploaded image
Frontend (ui/src/common/ImageUploadOverlay.jsx):
- Hover over artist image → camera icon overlay appears
- Click to upload, delete button shows when
hasUploadedImageis true - Gated by:
config.enableArtworkUpload === trueOR user is admin
DB: uploaded_image field on artist table (added in migration 20260315233131).
Priority: When ArtistArtPriority has uploaded-image first (default), your custom image takes precedence over everything — external APIs, folder images, album art.
Also works without the upload UI:
- Set
ArtistImageFolderconfig to a directory path - Drop files named
{MusicBrainz-ID}.jpgor{Artist Name}.jpgin that folder - They'll be picked up by the
image-folderpriority source
| Layer | File | Purpose |
|---|---|---|
| Model | model/artist.go |
Artist struct, ArtistImageUrl() method |
| Agent interfaces | core/agents/interfaces.go |
11 interfaces agents can implement |
| Agent orchestration | core/agents/agents.go |
Priority fallback iteration |
| Caching provider | core/external/provider.go |
TTL cache, background refresh queue |
| Image reader | core/artwork/reader_artist.go |
Source selection by ArtistArtPriority |
| Image sources | core/artwork/sources.go |
Local/external/URL fetch implementations |
| HTTP serving | server/public/handle_images.go |
JWT-authed image endpoint |
| Upload API | server/nativeapi/artists.go |
POST/DELETE artist image |
| UI entry | ui/src/artist/ArtistShow.jsx |
Artist detail page |
| UI desktop | ui/src/artist/DesktopArtistDetails.jsx |
Desktop bio + image layout |
| UI mobile | ui/src/artist/MobileArtistDetails.jsx |
Mobile layout |
| UI upload | ui/src/common/ImageUploadOverlay.jsx |
Upload overlay component |
flowchart TD
subgraph External["External Agents (priority order)"]
LFM["Last.fm"]
DEE["Deezer"]
LB["ListenBrainz"]
LOCAL["Local Agent"]
WASM["WASM Plugins"]
end
subgraph Backend["Backend"]
PROV["Provider\n(core/external/provider.go)"]
AGENTS["Agent Orchestrator\n(core/agents/agents.go)"]
DB[("SQLite DB\n- biography\n- image URLs\n- similar_artists\n- external_info_updated_at")]
CACHE[("File Cache\n(image_cache.go)")]
READER["Artist Image Reader\n(reader_artist.go)"]
end
subgraph Sources["Image Sources (ArtistArtPriority)"]
S1["1. uploaded-image"]
S2["2. external (agent URLs)"]
S3["3. image-folder"]
S4["4. album/artist.*"]
S5["5. folder.*"]
end
subgraph Frontend["React UI"]
SHOW["ArtistShow.jsx"]
DESK["DesktopArtistDetails"]
MOB["MobileArtistDetails"]
UPLOAD["ImageUploadOverlay"]
SAFE["SafeHTML (biography)"]
end
SHOW -->|"subsonic.getArtistInfo(id)"| PROV
PROV -->|"cache miss / expired"| AGENTS
AGENTS --> LFM & DEE & LB & LOCAL & WASM
LFM & DEE & LB & LOCAL & WASM -->|"bio, images, similar"| AGENTS
AGENTS --> PROV
PROV -->|"store"| DB
PROV -->|"return artistInfo"| SHOW
SHOW -->|"GET /img/{id}"| CACHE
CACHE -->|"miss"| READER
READER --> S1 & S2 & S3 & S4 & S5
S2 -->|"fetch URL"| DB
READER -->|"store"| CACHE
CACHE -->|"image bytes"| SHOW
SHOW --> DESK & MOB
DESK & MOB --> SAFE
DESK & MOB --> UPLOAD
UPLOAD -->|"POST/DELETE /api/artist/{id}/image"| S1
Images: Agents (Last.fm/Deezer) → cached URLs in DB → served via file cache with configurable priority. Custom images supported via upload UI or ArtistImageFolder directory — both take priority over external sources by default.
Bios: Agents fetch HTML bios (multi-language fallback on Last.fm) → cached 24h in DB with async background refresh → sanitized and rendered in collapsible UI section.