metadatarr

August 5, 2026 · View on GitHub

PyPI Python License: MIT Build

metadatarr is a set of Pydantic-powered Python clients for public media metadata catalogues, plus a cross-source entity resolver. It talks to the catalogues that the *arr ecosystem, media managers, and libraries rely on, then fuses the answers into one de-duplicated record with a canonical set of external IDs. Every built-in client and provider works without an API key.

It ships two ways to use it: as a Python library (pip install metadatarr), and as a self-contained HTTP server + Web UI (pip install "metadatarr[server]" or Docker) for anyone who'd rather point-and-click than write code.

TL;DR (60 seconds)

pip install metadatarr
from metadatarr.resolve import resolve
from mediavocab import Signals, MediaType

ids = resolve(Signals(title="Inception", year=2010, medium=MediaType.MOVIE)).external_ids
print(ids.tmdb_movie, ids.imdb, ids.wikidata)   # 27205 tt1375666 Q25188

That's it: no API keys, no config. resolve() fans out to every relevant catalogue, conflict-checks the answers, and hands you one merged set of external IDs. Need a different medium? Change MediaType.MOVIE to MUSIC, BOOK, PODCAST, … .

Nothing came back, or got None? See docs/troubleshooting.md: empty results are by design (silent-failure), and the troubleshooting guide explains why and how to debug it.


Web UI & server

metadatarr provides a self-contained HTTP server and a build-free Web UI (htmx, no JS build step, no CDN calls) — cross-catalogue disambiguation made visible instead of hidden behind a single "best guess."

Resolver Playground

Quickstart (pip):

pip install "metadatarr[server]"
metadatarr serve

Open http://localhost:8000/.

What you get out of the box (no API keys):

  • The Resolver Playground — run any query through every keyless provider and see the ranked candidates side by side with the consolidated result.
  • The /resolve, /candidates, /enrich, /providers, /healthz JSON API.
  • MusicBrainz, TVmaze, AniList, OpenLibrary, Anna's Archive, LibriVox, Bandcamp, SoundCloud, YouTube/YouTube Music, Wikidata, and more — no registration, no tokens.
  • Only TMDB, TVDB, and Discogs are key-gated; everything else works the moment the server starts.

Quickstart (Docker):

docker compose -f deploy/docker-compose.yml up -d --build

Builds from source against the repo checkout (git is a build-time dependency only, needed while a few first-party libs are still pinned to @dev refs). See deploy/ and docs/deploy.md for volumes, env vars, and healthchecks.

HTTP API:

EndpointWhat it does
POST /resolveRun the full resolver on a Signals body, get back a ResolveResult
POST /candidatesSame fan-out, but return every provider's raw vote unmerged
POST /enrichTake a partial ExternalIds and fill in the rest
GET /providersList built-in providers and whether each is currently available
GET /healthzLiveness check
curl -X POST http://localhost:8000/resolve \
  -H 'Content-Type: application/json' \
  -d '{"title": "Inception", "year": 2010, "medium": "movie"}'
# → a ResolveResult JSON body: external_ids, accepted, conflicts, provider_errors

curl -X POST http://localhost:8000/candidates \
  -H 'Content-Type: application/json' \
  -d '{"title": "Inception", "year": 2010, "medium": "movie"}'
# → every provider's raw vote, unmerged, sorted by confidence descending

curl http://localhost:8000/healthz
# → {"status": "ok", "version": "..."}

Most providers need no API key. Setting TMDB_API_KEY, TVDB_API_KEY, or DISCOGS_TOKEN unlocks the gated ones — see them flip on live in the providers grid:

Providers

There is no built-in authentication. This is meant for a single-tenant homelab box: put it behind a reverse proxy (Caddy, Traefik, nginx) if it's reachable outside your LAN.

Screenshots — responsive down to phone width, dark by default with a light theme toggle:

Mobile

Full tour of the pages (Resolver Playground, Providers, Mappings) in docs/webui.md.


Why metadatarr?

Most media tools need to cross-reference the same work across Sonarr, MusicBrainz, Discogs, and Wikidata: but every API has a different shape, auth model, and concept of "the same thing." metadatarr handles all of that:

  • Typed clients: every response parsed into Pydantic V2 models, no dict spelunking.
  • Keyless by default: every built-in provider works without registration or tokens.
  • Cross-source resolver: fans out to every relevant provider in parallel, conflict-checks the results, and merges winners into one ResolveResult with ExternalIds.
  • Variant fan-out: one flag (include_variants=True) and the resolver collects every known cut, edition, or fanedit of a work.
  • Batteries-included: pyfanedit, pymetal, tutubo, py_bandcamp, and nuvem_de_som are all core dependencies, no optional-extra juggling required.

Installation

pip install metadatarr

All first-party scrapers (pyfanedit, pymetal, tutubo, py_bandcamp, nuvem_de_som) are core dependencies: no extras required. The only optional extra is [test] for running the test suite.


Direct clients

Each client is a thin, typed wrapper around one data source.

ClientSourceWhat you get
ArrMetadataClientServarr proxies (Skyhook / Radarr / Lidarr)TV shows, movies, artists: same data that powers Sonarr/Radarr/Lidarr
OpenLibraryClientopenlibrary.orgWorks, editions, authors, ISBN lookup, covers
BookInfoClientrreading-glasses (Goodreads / Hardcover)Book metadata via Goodreads / Hardcover
AnnasArchiveClientAnna's Archive mirrorsBook search (HTML scrape)
ClientSourceWhat you get
AudioDBClienttheaudiodb.comArtists, albums, tracks
TVmazeClienttvmaze.comShows, seasons, episodes, cast, people
BlurayComClientblu-ray.comPhysical Blu-ray specs: audio tracks, region codes, extras
DVDCompareClientdvdcompare.netRegional release comparison, cut runtimes, version notes
ClientSourceWhat you get
DiscogsClientdiscogs.comVinyl, CD, cassette releases, search_video() for LaserDiscs / concert VHS / music DVDs
from metadatarr import ArrMetadataClient, OpenLibraryClient, AudioDBClient, TVmazeClient

# Movies & TV via Servarr proxies
arr = ArrMetadataClient()
movie  = arr.search_movie("Alien")[0]
series = arr.search_series("The Boys")[0]
artist = arr.search_artist("Moonsorrow")[0]
print(movie.tmdb_id, series.tvdb_id, artist.mb_id)

# Books
ol  = OpenLibraryClient()
hit = ol.search("The Hobbit", limit=1)[0]
print(hit.key, hit.first_publish_year)

# Music
db  = AudioDBClient()
alb = db.search_album("Voimasta ja Kunniasta")[0]
print(alb.id_album, alb.str_genre)

# TV
tv   = TVmazeClient()
show = tv.singlesearch("Severance")
print(show.id, show.network.name)

Cross-source resolver

When you have a title, a year, or a noisy filename and need a canonical identity across every platform, the resolver fans out, conflict-checks, and merges:

from metadatarr.resolve import resolve
from mediavocab import Signals, MediaType

# A basic lookup: metadatarr queries all active providers concurrently
result = resolve(Signals(title="OK Computer", artist="Radiohead", medium=MediaType.MUSIC))

print(result.external_ids.musicbrainz_release_group)  # MusicBrainz MBID
print(result.external_ids.wikidata)                   # Wikidata Q-id
print(result.external_ids.extra.get("bandcamp_album_id"))

# Inspect what was accepted and what was rejected
for m in result.accepted:
    print(f"  OK   {m.provider:<20} confidence={m.confidence:.2f}")
for d in result.conflicts:
    fields = ", ".join(f"{c.signal}({c.ours}!={c.theirs})" for c in d.fields)
    print(f"  DROP {d.provider:<20} clashed on {fields}")

# A provider that raised is swallowed to keep the run going, but recorded here —
# a populated list means upstream schema drift, not "no match".
for e in result.provider_errors:
    print(f"  ERR  {e.provider:<20} {e.stage} raised {e.error_type}: {e.message}")

Signals: tell the resolver what you know

from mediavocab import Signals, MediaType

signals = Signals(
    title    = "Alien",
    year     = 1979,
    medium   = MediaType.MOVIE,
    runtime  = 6900,          # seconds: used for cut-disambiguation
    language = "en",
    country  = "US",
)

Pass as much or as little as you have. Every field is optional. The more context you provide, the better providers can filter and the more aggressively conflicts are detected.

MediaType values: Comes from mediavocab: 18 canonical values (MOVIE, EPISODIC_SERIES, TV, MUSIC, MUSIC_VIDEO, PODCAST, BOOK, COMIC, GAME, AUDIOBOOK, AUDIO_DRAMA, RADIO, INTERACTIVE_FICTION, SOUND_EFFECT, AMBIENT_SOUNDS, PLAYLIST, GENERIC, NOT_MEDIA). See the mediavocab spec §4.1.

Variant fan-out: editions, cuts, fanedits

from metadatarr.resolve import resolve
from mediavocab import Signals, MediaType
from metadatarr.resolve.entities import EntityRole

result = resolve(Signals(
    title           = "Alien",
    year            = 1979,
    medium          = MediaType.MOVIE,
    include_variants= True,       # ← triggers second pass
))

for entity in result.variants:
    print(entity.name, entity.external_ids.fanedit_id)
    # Alien: Covenant Cut, Alien: The Director's Cut, ...

With include_variants=True the resolver runs a second pass calling list_variants() on every active provider:

  • pyfanedit: queries fanedit.org (IFDB) for fan-edited cuts of the movie
  • musicbrainz: expands a release-group MBID to its individual releases (editions, remasters, regional pressings)

ExternalIds: every platform in one object

from mediavocab import ExternalIds

ids = result.external_ids
print(ids.tmdb_movie)                          # int
print(ids.imdb)                                # "tt0078748"
print(ids.musicbrainz_release_group)           # UUID str
print(ids.wikidata)                            # "Q103569"
print(ids.extra.get("bandcamp_album_id"))      # platform extras

First-class typed fields: musicbrainz_*, imdb, tmdb_movie, tmdb_tv, tvdb, isbn_10, isbn_13, olid, goodreads, wikidata, metal_archives_*, fanedit_id, derived_from_imdb, discogs_release, bluray_com_id, dvdcompare_id, … plus an extra dict for platform-specific IDs (Bandcamp, SoundCloud, YouTube Music, …).


Tag your existing media library

metadatarr can add metadata to a library you already have — point it at a folder and it resolves each file and writes Jellyfin/Kodi .nfo sidecars next to your media, without touching the files themselves:

pip install "metadatarr[tag]"          # adds guessit + mutagen (filename/tag parsing)

metadatarr tag-library --path /media/Movies --dry-run   # preview: writes nothing
metadatarr tag-library --path /media/Movies             # write <name>.nfo sidecars
metadatarr tag-library --path /media/Music --media music

How it identifies each file:

  • Radarr/Sonarr-organized files — an embedded id in the name (Inception (2010) {tmdb-27205}.mkv) is used directly (expanded to the full cross-catalog id set) — the most reliable path.
  • Plain names — parsed to title/year and resolved via the providers. Resolution is year-aware (so Dawn of the Dead (1978) gets the original, not the 2004 remake) and subtitle-aware (… - The Two Towers keeps the subtitle). It also reads embedded container metadata via ffprobe (title, and any embedded tmdb/imdb tags) when available.
  • Music — reads embedded ID3/Vorbis tags; a track it still can't place falls back to audio fingerprinting (see Audio identification below).
  • Trailers/extras (-trailer, Trailers/, Extras/, …) are skipped.

Tagging is non-destructive — it writes .nfo sidecars beside your media and never touches the files themselves — and --dry-run previews every change.

Rename to a clean convention (opt-in)

--rename additionally organizes confidently-matched files to the Radarr/Jellyfin convention Title (Year) {tmdb-id}.ext. It is opt-in and safe: --dry-run previews every move, only confident matches are renamed (never an unidentified file), it never overwrites an existing target, keeps the .nfo name in sync, and moves atomically without touching file content.

metadatarr tag-library --path /media/Movies --rename --dry-run   # preview renames
metadatarr tag-library --path /media/Movies --rename             # e.g. → "Inception (2010) {tmdb-27205}.mkv"
metadatarr tag-library --path /media/Movies --rename --rename-folder   # Jellyfin movie-folder layout

Audio identification (Shazam)

Identify a song from the audio itself — a fingerprint → title/artist/ISRC → enriched to the full cross-catalog id set. Built on the xazam Shazam client:

pip install "metadatarr[identify]"     # adds xazam

metadatarr identify song.mp3           # → recognized title/artist + resolved ids
# or over HTTP: POST /identify/audio  (multipart file upload)

This is also the music fallback used by tag-library above.


Built-in providers

All providers are keyless. All dependencies are bundled in the core install.

Routing is three-axis: media, playback_type, and genre_filter. Pass playback_type on Signals to route a MediaType.GENERIC query to audio-only or video-only providers. See docs/resolve.md for details.

ProviderSourceMediaTypeModality
skyhookServarr proxiesMovie, EpisodicSeries, Music, Bookuniversal
musicbrainzMusicBrainz APIMusicAUDIO
audiodbTheAudioDBMusicAUDIO
tvmazeTVmaze public APIEpisodicSeriesVIDEO
ProviderSourceMediaTypeModality
anilistAniList GraphQL APIMovie, EpisodicSeries, ComicVIDEO + TEXT
jikan_animeJikan (MyAnimeList)Movie, EpisodicSeriesVIDEO
jikan_mangaJikan (MyAnimeList)ComicTEXT
librivoxLibriVox APIAudiobookAUDIO
ProviderSourceMediaTypeModality
apple_podcastsApple Podcasts searchPodcast, AudioDramaAUDIO
wikidataWikidata APIAlluniversal
discogsDiscogs APIMusic, MusicVideo, GenericAUDIO + VIDEO
bluray_comblu-ray.com scraperMovieVIDEO
ProviderSourceMediaTypeModality
dvdcomparedvdcompare.net scraperMovieVIDEO
pyfaneditfanedit.org / IFDBMovie (variants)VIDEO
bandcampBandcampMusicAUDIO
soundcloudSoundCloudMusicAUDIO
ProviderSourceMediaTypeModality
youtube_musicYouTube MusicMusicAUDIO
youtubeYouTubeVideo, Podcast, Genericuniversal
metal_archivesEncyclopaedia MetallumMusicAUDIO
openlibraryOpenLibraryBookTEXT
ProviderSourceMediaTypeModality
annas_archiveAnna's ArchiveBookTEXT

YouTube vs YouTube Music: these are intentionally separate providers. youtube only emits channel IDs and refuses MediaType.MUSIC lookups (video IDs aren't canonical music identities). youtube_music has proper entity records: stable browseId values for artists and albums that are safe to treat as cross-references.


Identity mappings

Some artists and labels are the same entity across platforms but no database records the link. Declare it once in a TOML file and every resolver run picks it up automatically:

# ~/.config/metadatarr/mappings.toml

[[artist]]
name                = "Acidkid / Piratech"
soundcloud_artist_url = "https://soundcloud.com/acidkid"
bandcamp_artist_url   = "https://piratech.bandcamp.com/"

[[artist]]
name               = "Moonsorrow"
musicbrainz_artist = "6a0a7b9b-9e12-4e1c-b91d-67cedf98a6c3"
bandcamp_band_id   = "3498887240"
metal_archives_band= 27

The package ships a curated metadatarr/data/mappings.toml. Your user file at ~/.config/metadatarr/mappings.toml extends it: entries that share any identifier are merged, new entries are appended. Send a PR to add publicly-verifiable cross-platform links to the package file.


Writing a custom provider

from typing import Optional
from metadatarr.resolve.base import MetadataProvider, ProviderMatch, register
from mediavocab import ExternalIds
from mediavocab import Signals, MediaType


class MyProvider(MetadataProvider):
    name  = "my_provider"
    media = {MediaType.MUSIC}

    def is_available(self) -> bool:
        return True

    def lookup(self, signals: Signals) -> Optional[ProviderMatch]:
        if not signals.title:
            return None
        result = my_api.search(signals.title)
        if not result:
            return None
        return ProviderMatch(
            provider   = self.name,
            confidence = 0.7,
            signals    = Signals(title=result["title"], medium=MediaType.MUSIC),
            external_ids = ExternalIds(
                musicbrainz_artist = result.get("mbid"),
                extra = {"my_platform_id": str(result["id"])},
            ),
        )


register(MyProvider())

Provider guidelines:

  • Guard optional imports: wrap import my_lib in try/except ImportError, set self._available = False on failure.
  • Canonical IDs only: numeric platform IDs are stable, URL slugs are not. Store URLs as *_url extra keys.
  • Refuse wrong mediums: return None if signals.medium isn't in your media set.
  • Confidence guide: 0.9 for exact-ID lookups, 0.7 for strong-signal search, 0.5 to 0.6 for fuzzy/unreliable sources.

Physical media

BlurayComClient and DVDCompareClient expose Blu-ray and DVD edition data that no structured API covers: region codes, audio track specs, cut runtimes, regional extras:

from metadatarr.resolve.providers.bluray_com import BlurayComProvider
from metadatarr.resolve.providers.dvdcompare import DVDCompareProvider
from mediavocab import Signals, MediaType

signals = Signals(title="Moon", year=2009, medium=MediaType.MOVIE)

bluray = BlurayComProvider()
match  = bluray.lookup(signals)
if match:
    print(match.external_ids.bluray_com_id)

dvd    = DVDCompareProvider()
match  = dvd.lookup(signals)
if match:
    print(match.external_ids.dvdcompare_id)

See docs/physical-disc.md for a full walkthrough.


Caching and concurrency

resolve() is concurrent (default 8 workers via ThreadPoolExecutor) and process-level cached:

from metadatarr.resolve._cache import cache

cache().hits    # int: cached lookups served
cache().misses  # int: network hits
cache().clear() # force re-fetch (e.g. after adding a new provider)

Both hits and misses are cached, so failed lookups don't re-hit the network on retry. Pass resolve(signals, max_workers=N) to tune parallelism.

HTTP itself flows through a shared session (metadatarr.transport) that adds per-host rate limiting and an opt-in disk cache for first-party requests. Enable the disk cache with an environment variable — no code change:

METADATARR_HTTP_CACHE=1 python your_script.py

See docs/transport.md for the rate-limit table and cache environment variables.


Documentation

DocContents
docs/getting-started.mdInstall, first calls, common patterns
docs/models.mdFull Pydantic model reference
docs/resolve.mdSignals, providers, ResolveResult, conflict detection
docs/providers.mdProvider catalogue: config, optional deps, caveats
docs/webui.mdWeb UI pages, HTTP API, running it
docs/deploy.mdDocker deployment: env vars, volumes, reverse proxy
DocContents
docs/recipes.mdEnd-to-end snippets for common tasks
docs/transport.mdShared HTTP session — rate limits, disk cache, env vars
docs/physical-disc.mdBlu-ray / DVD edition data
docs/troubleshooting.mdGotchas and FAQ
docs/add-provider.mdChecklist for adding a new resolver provider
DocContents
docs/testing.mdOffline-fixture / mocked-HTTP test pattern
CONTRIBUTING.mdBranch/PR flow, conventional commits → versioning
docs/clients/Per-client deep dives
examples/One focused script per use case

metadatarr bundles these first-party scrapers as core dependencies:

metadatarr also depends on mediavocab for its Signals, MediaType, and ExternalIds model layer.


Contributing

PRs welcome. Branch off dev, keep changes small, keep tests green. Commit messages use conventional commits: the version bumps automatically, so never edit version.py. To add a new source to the resolver, follow docs/add-provider.md. See CONTRIBUTING.md for the full flow.


Testing

pip install -e ".[test]"
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest -q

Tests are fully offline: all HTTP calls are stubbed with fixture files. The same command runs in CI. See docs/testing.md.


License

MIT: see LICENSE.