Skip to content

Providers and coalesce

The provider subsystem (internal/provider) populates artist metadata from external sources such as MusicBrainz, Fanart.tv, and Discogs. The entry point for a refresh is internal/provider.Orchestrator.FetchMetadata. The orchestrator holds a registry of Provider adapters and a settings service that supplies the per-field priority order. Each adapter enforces its own rate limit before sending a request, and every HTTP round-trip is wrapped so that 429 and 503 responses are handled before the adapter's own status switch sees the response.

Fan-out and coalesce flow

FetchMetadata loads the per-field priority list, then walks each field in order. For each field it iterates the enabled providers, calling getProviderResult per provider. That call checks an in-memory, mutex-protected per-call cache so each provider is queried at most once per FetchMetadata invocation no matter how many fields list it (the coalesce). Text fields stop at the first provider that populates the field (first-match-wins); image fields and tag-slice fields (genres, styles, moods) continue across all providers and aggregate candidates.

flowchart TD
    Entry["FetchMetadata\n(Orchestrator)"]
    LoadPriorities["Load per-field priority\n(SettingsService.GetPriorities)"]
    FieldLoop["For each field\n(ordered providers)"]
    Available{"Provider available?\n(API key set, not excluded)"}
    Skip["Skip provider"]
    Cache{"Result cached\nfor this provider?"}
    RateLimit["RateLimiterMap.Wait\n(token bucket per provider)"]
    Retry["DoWithRetry\n(429 / 503 backoff)"]
    Adapter["Adapter GetArtist\n/ GetImages"]
    Store["Cache providerResult\n(coalesce: one call per provider)"]
    Enrich["EnrichProviderIDs\n(carry forward discovered IDs)"]
    Apply["Apply to field\n(first-match-wins or aggregate)"]
    MBOverride["MusicBrainz name / sort-name\nauthoritative override"]
    Return["Return metadata + images\n+ per-field sources"]

    Entry --> LoadPriorities
    LoadPriorities --> FieldLoop
    FieldLoop --> Available
    Available -->|"no"| Skip
    Available -->|"yes"| Cache
    Cache -->|"hit"| Apply
    Cache -->|"miss"| RateLimit
    RateLimit --> Retry
    Retry --> Adapter
    Adapter --> Store
    Store --> Enrich
    Enrich --> Apply
    Apply --> FieldLoop
    FieldLoop --> MBOverride
    MBOverride --> Return

Adapter contract

The Provider interface (internal/provider/provider.go) is small: every adapter implements Name, RequiresAuth, SearchArtist, GetArtist, and GetImages. Optional extension interfaces add capabilities without widening the base contract: TestableProvider adds a connection test, NameLookupProvider signals that GetArtist can accept an artist name (the MBID-not-found name-retry path), and MirrorableProvider supports self-hosted mirrors. Adapters signal "no data" with *ErrNotFound and transient failure with *ErrProviderUnavailable. The orchestrator treats the two differently: an ErrNotFound is a successful query (stale data may be cleared), while a transient error leaves the field unqueried so existing data is preserved.

Priority resolution

Per-field provider priority is stored in the settings table and loaded by SettingsService.GetPriorities, which falls back to DefaultPriorities for any field without a stored row. The enabled-provider filter drops any provider in the per-field disabled set before iteration. A static exclusion map (fieldProviderExclusions in internal/provider/orchestrator.go) removes structurally incapable providers up front; for example MusicBrainz and Wikidata are excluded from the biography field. MusicBrainz is authoritative for the artist name and sort name: those are overwritten after the main loop regardless of iteration order.

Rate limiting and respectful backoff

RateLimiterMap (internal/provider/ratelimit.go) holds one golang.org/x/time/rate token-bucket limiter per provider, constructed once at startup and shared by every adapter and background job. Before every request an adapter calls RateLimiterMap.Wait, which blocks until a token is available or the context is canceled. Above the limiter sits DoWithRetry (internal/provider/retry.go), which wraps the HTTP closure: on a 429 it honors the Retry-After header (both delta-seconds and HTTP-date forms via parseRetryAfter) and otherwise applies full-jitter exponential backoff capped at the policy maximum; a 503 (how MusicBrainz signals throttling, often with no header) gets a smaller attempt budget. The limiter wait lives inside the retried closure, so every re-attempt still respects the per-provider budget. Transport-level errors are returned immediately and never retried. The full per-provider rate-limit table is generated into the provider matrix; this page states the design intent rather than restating the numbers.

Image bridge

internal/imagebridge resolves a Stillwater artist ID to platform-specific image URLs and bytes. It exists to break an import cycle: the Emby and Jellyfin clients import internal/connection, so connection cannot import them. The Bridge takes a connection service plus a narrow artist-ID provider, resolves the artist to its per-platform IDs, and walks them in order to fetch or upload images against the right client. The bridge is not a Provider adapter and does not participate in priority or rate limiting; it is a post-resolution transport layer used by the publish path.

Where to look

Topic File
Provider interface, error types, capabilities internal/provider/provider.go
Orchestrator, FetchMetadata, getProviderResult, EnrichProviderIDs internal/provider/orchestrator.go
FieldPriority, DefaultPriorities, GetPriorities internal/provider/settings.go
RateLimiterMap and per-provider limits internal/provider/ratelimit.go
DoWithRetry, RetryPolicy, parseRetryAfter, Clock internal/provider/retry.go
Adapter example (limiter then DoWithRetry then status switch) internal/provider/musicbrainz/musicbrainz.go
Image bridge internal/imagebridge/bridge.go

The pass-level provider cache that shares results across artists in one rule run is documented in Rule engine; the write gate that follows a successful fetch is in Conflict gate.

See also Architecture decisions for the ADRs on singleton rate limiters and respectful backoff, and on ID-first matching, that shaped this subsystem.