Skip to content

Metadata plugins

Once your libraries are scanning files, Orb enriches what it finds — movies, TV shows, episodes, artists, albums, tracks, books, comics, and lyrics — by dispatching each enrichment to a metadata plugin. This is where the artwork, descriptions, cast, and tags come from.

Builtin plugins ship with Orb; user-supplied HTTP and embedded-script plugins arrive in later phases (see the provider matrix below).

This document covers:

  • The current builtin providers and what each one can enrich
  • How per-library provider selection works
  • How to add a new builtin (Go) plugin
  • How plugin configuration and hot-reload work end-to-end

Provider matrix

Each plugin advertises one or more capabilities. The library editor's "Metadata plugins" panel only lets you pick plugins whose capabilities apply to that library's media type.

Plugin Status Auth Movies Shows Episodes Artists Albums Tracks Books Comics Lyrics
TMDB (themoviedb.org) ✅ Builtin API key
TheTVDB (thetvdb.com) ✅ Builtin API key (+ optional PIN)
TVMaze (tvmaze.com) ✅ Builtin None
MusicBrainz (musicbrainz.org) ✅ Builtin None (contact recommended)
Discogs (discogs.com) ✅ Builtin Token (optional but recommended)
OpenLibrary (openlibrary.org) ✅ Builtin None
LRCLIB + NetEase (via lyricfetch) ✅ Builtin None
Wikidata SPARQL ✅ Builtin None (contact optional)
OMDb (omdbapi.com) ✅ Builtin API key (free)
iTunes (apple.com) ✅ Builtin None (public API)
Cinemeta (Stremio) ✅ Builtin None (public API)
Last.fm (last.fm) ✅ Builtin API key (free)
Google Books (books.google.com) ✅ Builtin API key (optional)
AniList (anilist.co) ✅ Builtin None (public GraphQL)
AniDB (anidb.net) ✅ Builtin Client name (registered)
Genius (genius.com) ✅ Builtin API access token
MangaDex (mangadex.org) ✅ Builtin None (public API)
Comic Vine (comicvine.gamespot.com) ✅ Builtin API key (free)
Jikan / MyAnimeList (jikan.moe) ✅ Builtin None (public API)
Kitsu (kitsu.io) ✅ Builtin None (public API)
MangaUpdates (mangaupdates.com) ✅ Builtin None (public API)
Metron (metron.cloud) ✅ Builtin Account (username + password)
Marvel (developer.marvel.com) ✅ Builtin Public + private API keys (free)
OpenSubtitles 🟡 Planned API key (optional) subtitles subtitles subtitles
Audible (scraper) 🟡 Planned None
HTTP user plugin 🔵 Roadmap (Phase 2) URL + manifest varies varies varies varies varies varies varies varies varies
Starlark script plugin 🔵 Roadmap (Phase 2) Script upload varies varies varies varies varies varies varies varies varies

✅ shipped 🟡 planned (covered by the existing interfaces — needs an adapter) 🔵 roadmap (needs new infrastructure)


Per-library selection

Library settings (the JSON blob on each row of the libraries table) carry two plugin-related fields:

{
  // existing tunables (workers, enrich, exclude_globs, …)
  "providers_v2": true,
  "providers": {
    "show_metadata":    { "ids": ["tmdb", "tvdb", "tvmaze"], "mode": "fallback" },
    "episode_metadata": { "ids": ["tmdb", "tvdb"],           "mode": "fallback" },
    "artist_metadata":  { "ids": ["musicbrainz", "discogs"], "mode": "merge"    }
  }
}
  • providers_v2: false (default for migrated libraries) — the ingester runs the legacy hardcoded chain. The admin panel can configure plugin credentials, but per-library selection has no effect.
  • providers_v2: true — the ingester dispatches through plugin.Registry for every capability. Missing entries fall back to the curated default for the media type (see medialibrary.DefaultProviders).
  • Empty ids array means "no providers for this capability" — explicit opt-out.
  • mode: "fallback" runs providers in order and returns the first non-nil result. Good for movies/shows/episodes where one good answer is enough.
  • mode: "merge" runs every configured provider and returns all non-nil results so the caller can combine them (e.g. MusicBrainz fields + Discogs styles).

The library edit modal has a "Metadata plugins" section that drives this UI: a checkbox to enable v2 dispatch, then per-capability provider toggles with up/down reordering and a fallback/merge selector.


Configuring credentials

Open Admin → Plugins. Each registered plugin is listed with its capabilities and a Ready/Not-configured badge. Expanding a card reveals the config form generated from the plugin's ConfigSchema():

  • Required fields must be set before Ready flips on.
  • Secret fields are masked as •••••••• after the first save; resubmitting the mask is a no-op (the existing value is preserved).
  • Saving calls PUT /admin/plugins/{id}/config, which persists every field under site_settings.plugin:<id>:<key> and hot-reloads the in-memory client — changes apply on the next scan without a restart.

The first boot after upgrading from a pre-plugin DB runs an idempotent migration in services/internal/store/migrate.sql that copies legacy keys (tmdb_api_key, tvdb_api_key, tvdb_pin, discogs_api_token, musicbrainz_endpoint, musicbrainz_contact) into the new plugin:* namespace.


Adding a new builtin plugin

A builtin plugin is a Go package under services/internal/plugins/ that:

  1. Implements plugin.Plugin (base interface — ID(), Name(), Description(), Capabilities(), ConfigSchema(), Configure(), Ready()).
  2. Implements one or more capability sub-interfaces (MovieEnricher, ShowEnricher, EpisodeEnricher, ArtistEnricher, AlbumEnricher, TrackEnricher, BookEnricher, LyricsProvider).
  3. Registers itself in buildPluginRegistry() so it's discovered at boot.

The pattern is thin adapter over an existing HTTP client. The adapter handles auth/config; the underlying client handles request/response/rate-limiting.

Worked example: a wikidata movie plugin

1. Create the package at services/internal/plugins/wikidata/wikidata.go:

package wikidataplugin

import (
    "context"
    "strings"
    "sync"

    "github.com/alexander-bruun/orb/services/internal/plugin"
    "github.com/alexander-bruun/orb/services/internal/wikidata" // your client
)

const (
    ID   = "wikidata"
    Name = "Wikidata"
)

type Plugin struct {
    mu     sync.RWMutex
    client *wikidata.Client
}

func New() *Plugin { return &Plugin{client: wikidata.New()} }

func (p *Plugin) ID() string                       { return ID }
func (p *Plugin) Name() string                     { return Name }
func (p *Plugin) Description() string              { return "Open data movie metadata from Wikidata SPARQL" }
func (p *Plugin) Capabilities() []plugin.Capability {
    return []plugin.Capability{plugin.CapMovieMetadata}
}
func (p *Plugin) ConfigSchema() []plugin.ConfigField        { return nil } // no-key
func (p *Plugin) Configure(values map[string]string) error  { return nil }
func (p *Plugin) Ready() bool                               { return p.client != nil }
func (p *Plugin) Raw() any                                  { return p.client }

func (p *Plugin) EnrichMovie(ctx context.Context, q plugin.MovieQuery) (*plugin.MovieResult, error) {
    if strings.TrimSpace(q.Title) == "" {
        return nil, nil
    }
    hit, err := p.client.LookupMovie(ctx, q.Title, q.Year)
    if err != nil || hit == nil {
        return nil, err  // (nil, nil) means "no confident match" — registry advances
    }
    return &plugin.MovieResult{
        Provider:    ID,
        ProviderID:  hit.QID,
        Title:       hit.Title,
        Overview:    hit.Description,
        ReleaseDate: hit.ReleaseDate,
        PosterURL:   hit.PosterURL,  // absolute URL — adapter resolves these
        ExternalIDs: map[string]string{"wikidata_qid": hit.QID},
    }, nil
}

2. Register it in services/cmd/main.go by adding to the builtins slice in buildPluginRegistry():

import wikidataplugin "github.com/alexander-bruun/orb/services/internal/plugins/wikidata"

builtins := []plugin.Plugin{
    tmdbplugin.New(),
    // …
    wikidataplugin.New(),
}

3. (Optional) add it to the curated default chain in medialibrary.DefaultProviders:

case store.MediaTypeMovie:
    return map[string]plugin.Selection{
        string(plugin.CapMovieMetadata): {IDs: []string{"tmdb", "wikidata"}, Mode: plugin.ModeFallback},
    }

That's it. Rebuild and start the server — the plugin shows up in the Admin → Plugins panel and in every library's Metadata plugins picker for capabilities it advertises.

Contract rules

  • Return (nil, nil) for "no confident match" (not an error). The registry treats nil as "miss" and advances to the next provider in fallback mode.
  • Image URLs must be absolute — resolve any provider-specific path/size helpers inside the adapter. HTTP and Starlark plugins (Phase 2+) can only emit flat strings, so the contract has to be.
  • Don't return secrets or raw API responses in MovieResult / ShowResult / etc. — those are the normalized shapes the ingesters consume.
  • Configure() is called at boot and on every admin save. It should be idempotent and cheap — recreating the underlying client is fine; doing blocking I/O is not.
  • Concurrency-safe: enrichment runs from multiple ingest workers. Guard mutable client state with a mutex. The base TMDB / TVDB / TVMaze / MusicBrainz / Discogs clients in services/internal/{tmdb,tvdb,…} are already concurrency-safe.

When to expose Raw()

registry.Raw(id) returns the underlying client pointer (e.g. *tmdb.Client) for the rare cases the Plugin interface can't model — chiefly auth-gated image downloads (Discogs needs the token) and provider-specific URL-relation traversal (MusicBrainz Wikidata images). Use it sparingly; prefer adding a new sub-interface if a need recurs.


What's deferred

  • Music ingest (enrichAfterIngest) still calls the legacy g.mb / g.dc clients directly because its image-fetch helpers (mb.FetchArtistImage, dc.FetchImage) don't fit through the normalized ArtistResult / AlbumResult shapes. The MB and Discogs plugins are registered (for credential management and to verify the abstraction) but selecting different plugins per-library has no behavioural effect on music yet — Phase 2 reworks this call site.
  • AniDB title search — AniDB's HTTP API requires an integer AID for all detail lookups. Title→AID resolution requires the offline titles dump (anime-titles.xml.gz), which is not loaded at runtime. The plugin is registered and enriches by AID when provided via a cross-reference from another plugin (ShowProviderID). Full search support is deferred to a Phase 2 caching layer.
  • Genius synced (LRC) lyrics — Genius does not expose synced lyrics via its public API. The plugin returns plain-text lyrics only; use LRCLIB + NetEase (lyricfetch) for synced lyrics.
  • Audible (scraper) — Audible has no public API; scraping is fragile and subject to ToS changes. Deferred pending a stable data source.
  • HTTP user plugins — POST request/response to a user-supplied URL. The interfaces are already designed to accept a non-Go implementation; only the dispatcher and admin UI are missing.
  • Starlark script plugins — script upload + sandboxed in-process execution. Same shape as HTTP, different runtime. No runtime dep added yet.
  • OpenSubtitles as a SubtitlesProvider capability — subtitles dispatch is per-file rather than per-entity, so it deserves its own sub-interface. (OpenSubtitles downloads already work today during video ingest when credentials are set under Admin → Settings → Integrations; only the formal plugin capability is deferred.)

Next steps