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 throughplugin.Registryfor every capability. Missing entries fall back to the curated default for the media type (seemedialibrary.DefaultProviders).- Empty
idsarray 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():
Requiredfields must be set beforeReadyflips on.Secretfields 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 undersite_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:
- Implements
plugin.Plugin(base interface —ID(),Name(),Description(),Capabilities(),ConfigSchema(),Configure(),Ready()). - Implements one or more capability sub-interfaces (
MovieEnricher,ShowEnricher,EpisodeEnricher,ArtistEnricher,AlbumEnricher,TrackEnricher,BookEnricher,LyricsProvider). - 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 legacyg.mb/g.dcclients directly because its image-fetch helpers (mb.FetchArtistImage,dc.FetchImage) don't fit through the normalizedArtistResult/AlbumResultshapes. 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
SubtitlesProvidercapability — 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¶
- Your library — back to library setup.
- Movies & TV · Comics & manga — media types that lean heavily on these providers.