Pointing your stack at this server
Sonarr, Radarr and most TMDB clients cannot be configured to use a different metadata source. The URLs are compiled into the applications:
| Client | Hostname it calls | Where that is set |
|---|---|---|
| Sonarr | skyhook.sonarr.tv |
NzbDrone.Common/Cloud/SonarrCloudRequestBuilder.cs |
| Sonarr, for its alternate titles (optional) | services.sonarr.tv |
the same file |
| Radarr | api.radarr.video |
NzbDrone.Common/Cloud/RadarrCloudRequestBuilder.cs |
| Jellyseerr / Overseerr | api.themoviedb.org |
the TMDB SDK it bundles |
| Yamtrack, Jellyfin’s TheTVDB plugin, Kodi’s scraper | api4.thetvdb.com |
the TheTVDB client each bundles |
| Yamtrack’s imports, the anime trackers | graphql.anilist.co |
the AniList client each bundles |
Sonarr v4 asks skyhook.sonarr.tv under /v1/, Sonarr v5 under /v2/ — the
second edition of the Skyhook API, which carries the languages a series is
named in and the orders its episodes come in. Both are answered by the same
surface, and nothing below differs between the two. What each is given is in
Sonarr and Radarr.
So substituting this server is done at the network layer: resolve those hostnames to it, and make the client trust the certificate it presents. That is the whole technique. Everything below is detail.
The server has two doors, and they are independent:
AMS_BIND_ADDRESS(8080) — the web UI and the native API. Plain HTTP, or TLS from your own files (AMS_TLS_CERT/AMS_TLS_KEY), or behind whatever reverse proxy you already run. Nothing below touches it.AMS_CLIENTS_BIND(unset by default;0.0.0.0:443opens it) — Sonarr’s, Radarr’s and the relays’ surfaces (TMDB, TheTVDB, AniList), and nothing else, always in TLS, under the names above. Its certificate is issued by the server itself, from an authority it makes on first start, and renewed before it runs out. No reverse proxy, no certificate tooling.
1. Open the clients’ door
Section titled “1. Open the clients’ door”AMS_CLIENTS_BIND=0.0.0.0:443On first start the server creates, under AMS_TLS_DIR (data/tls; /data/tls
in the image):
ca.crt/ca.key— its authority, what the clients must trust;server.crt/server.key— the certificate the door shows, for all six hostnames (and any you add withAMS_CLIENTS_NAMES=ams.lan,192.168.1.7), good for a year and issued anew when fewer than thirty days remain — loaded without a restart, by the dailytls.renewtask.
The authority is constrained to those names (RFC 5280 name constraints): a
machine that trusts it trusts this server for skyhook.sonarr.tv and its like,
and for nothing else on the web. ca.key is still a key your stack trusts:
keep it off shared storage. Adding names later that the constraints do not
cover leaves them out of the certificate, with a warning; the Opening & APIs
page lists them. An authority made before this server answered for
services.sonarr.tv, or before it answered for TheTVDB’s and AniList’s
names, is one such case. To cover them, set
AMS_TLS_REPLACE_AUTHORITY to the authority’s fingerprint and restart: a new
authority is made for every name, once, the old one kept beside it
(ca.crt.replaced-… in the directory, or in the database with several
instances), and every client has to trust the new one — see
Upgrading a server that is already running.
Bringing your own certificate instead — a wildcard, an internal CA of yours —
is AMS_CLIENTS_TLS_CERT / AMS_CLIENTS_TLS_KEY; no authority is made then.
The authority’s certificate is served at /ca.crt on both doors, and the
script that installs it at /trust-ca.sh; the administration’s Opening &
APIs page shows the door, its names, the certificate’s end and the
fingerprints, with both files to download and a Renew now.
2. Port 443
Section titled “2. Port 443”The clients call https:// on the default port, so the door has to be on it.
In a container that is nothing special: Docker lets a container’s process bind
any port, non-root included (net.ipv4.ip_unprivileged_port_start=0 in its
namespace, since 20.10), so AMS_CLIENTS_BIND=0.0.0.0:443 and "443:443" do.
On an older engine, bind 8443 and map "443:8443". Outside a container on
Linux, setcap cap_net_bind_service=+ep on the binary; macOS asks nothing.
With several instances (AMS_MODE=multi, see
Several instances), every instance has a door of its own on 443, and all of them
serve the one certificate the authority in the database issued: point the
names at any of them, at a TCP balancer in front of them, or — in Compose —
give every instance the three names as network aliases, as
compose.multi.yaml does, so Docker’s DNS hands a client either address.
/ca.crt is the same on each.
The instances tell each other what changed over the cache server’s channel,
which delivers at most once: a word published while one of them
re-subscribes is lost. So each also reads the settings, the network rules
and the caches’ numbers again every minute — a change made on one instance
reaches the others within a minute at worst, never only at their restart.
What is on that server is trusted by every instance — the cached works and
answers, what the channel says — so give the instances an account of their
own that reaches their prefix and their channel and nothing else
(ACL SETUSER ams on >… ~ams:* &ams:events +@all), over TLS (rediss://)
when the server is not on the same host.
3. Redirect each client
Section titled “3. Redirect each client”Docker Compose
Section titled “Docker Compose”Give the metadata server a fixed address on a shared network and override the hostname per client:
services: metadata: networks: stack: ipv4_address: 172.31.0.10
sonarr: extra_hosts: - "skyhook.sonarr.tv:172.31.0.10" # Optional: Sonarr's alternate titles from this catalogue, below. - "services.sonarr.tv:172.31.0.10"
yamtrack: extra_hosts: - "api.themoviedb.org:172.31.0.10" - "api4.thetvdb.com:172.31.0.10" - "graphql.anilist.co:172.31.0.10"A complete, runnable example is in
compose.integration.yaml.
Outside Docker
Section titled “Outside Docker”Add the same mapping to /etc/hosts on each client host, or create the records
on your local DNS resolver. A resolver is the better choice for more than one or
two machines — it is one place to change when the address moves.
4. Trust the CA
Section titled “4. Trust the CA”The redirect is useless until the client accepts the certificate. A client that
does not trust it fails with unable to get local issuer certificate.
Sonarr and Radarr. Installing a certificate is not a matter of mounting
it: the linuxserver.io images do not rebuild their trust bundle on their own.
They do run anything executable in /custom-cont-init.d/ before starting,
which is what docker/trust-ca.sh is for. It takes
the authority from the first of: a file mounted at
/usr/local/share/ca-certificates/arr-metadata.crt; the server’s data volume
mounted read-only at /arr-metadata (tls/ca.crt in it); or AMS_CA_URL,
fetched from the server’s web door, with retries while the server starts. In a
compose stack the URL is the simplest, and the only one that shares nothing
else — the data volume holds ca.key and the database, which a client’s root
could read:
environment: AMS_CA_URL: http://172.31.0.10:8080/ca.crt AMS_CA_FINGERPRINT: 04:05:2E:B0:… # the SHA-256 Opening & APIs showsvolumes: - ./docker/trust-ca.sh:/custom-cont-init.d/10-trust-arr-metadata-ca:rodepends_on: metadata: condition: service_healthyThat fetch is plain HTTP on the stack’s own network, and what it installs is a
root the container then trusts for everything — so over http:// the script
refuses to install anything it cannot check: set AMS_CA_FINGERPRINT to the
SHA-256 shown on the Opening & APIs page, and anything else is refused. A
stack’s very first start has no fingerprint to copy yet; AMS_CA_INSECURE=1
accepts the fetch unpinned, with a warning in the container’s log, on a
network you control — compose.integration.yaml sets it until a fingerprint
is given in .env. An https:// address is checked by the container’s own
trust store, as any other.
Deploying from the image alone, without this repository? The script is what the
server serves at /trust-ca.sh: curl -o trust-ca.sh http://<server>:8080/trust-ca.sh. The container logs [trust-ca] authority installed when it worked, and fails loudly when it found nothing to install.
Jellyseerr and Overseerr. Node merges a PEM file with its built-in roots,
so nothing in the image needs modifying — download ca.crt once from the page
(or /ca.crt), put it beside the compose file, and mount it:
environment: NODE_EXTRA_CA_CERTS: /etc/arr-metadata-ca.crtvolumes: - ./arr-metadata-ca.crt:/etc/arr-metadata-ca.crt:roYamtrack, and other Python clients. requests verifies against its own
bundle and ignores the system store, and REQUESTS_CA_BUNDLE replaces that
bundle rather than adding to it — set to ca.crt alone, every other call the
application makes (MyAnimeList, IGDB, its identity provider) fails. Make a
bundle of the roots the image carries and the authority, and mount that:
curl -o arr-metadata-ca.crt http://172.31.0.10:8080/ca.crtdocker run --rm --entrypoint python ghcr.io/fuzzygrim/yamtrack:latest \ -c 'import certifi, sys; sys.stdout.write(open(certifi.where()).read())' > yamtrack-ca-bundle.crtcat arr-metadata-ca.crt >> yamtrack-ca-bundle.crtenvironment: REQUESTS_CA_BUNDLE: /etc/yamtrack-ca-bundle.crt # A key issued on Opening & APIs › Keys, in place of its TheTVDB key and # of its TMDB key: this server signs it in, and stands in with its own. # Every name redirected to this server wants the key — a redirected # api.themoviedb.org with Yamtrack's own TMDB key still in place is a 401 # on every TMDB call. TVDB_API: ams_… TMDB_API: ams_…volumes: - ./yamtrack-ca-bundle.crt:/etc/yamtrack-ca-bundle.crt:roWindows, or a client outside Docker. Download ca.crt from the
administration page and import it into the trusted root store (certmgr, or
Import-Certificate -CertStoreLocation Cert:\LocalMachine\Root); on Linux, drop
it into /usr/local/share/ca-certificates/ and run update-ca-certificates.
Any other .NET client on Alpine or Debian: mount into
/usr/local/share/ca-certificates/ and make sure update-ca-certificates runs
before the application starts.
5. Allow the clients through
Section titled “5. Allow the clients through”Sonarr and Radarr cannot attach an API key — there is nowhere in the protocol to put one. Those surfaces are guarded by address instead, by the list on the Access page. Before the first start, the environment may give that list its first value:
AMS_ALLOWLIST=172.31.0.20,172.31.0.21It is read once, against an empty table, and not again: from then on the list is edited on the Access page, where a change applies at once. A server started with the variable still set, and different from the list, says so in its log. Unset, the list starts as loopback and the private ranges, which is right for a single Docker host and too wide for a shared network.
Name the clients’ own addresses rather than the network they are on — give them
fixed ones (ipv4_address), as compose.integration.yaml does. A network’s
first address is its gateway, Docker’s, and whatever reaches the host’s
published ports through Docker’s own proxy arrives from it: allowing
172.31.0.0/24 allows that too.
If this server sits behind a reverse proxy, the allowlist is only as good as
the address resolution behind it — set AMS_TRUSTED_PROXIES to the proxy’s
address, or every client looks like the proxy. A request forwarded by a proxy
that is not in that list — it carries X-Forwarded-For, Forwarded or
X-Real-IP — is therefore refused on these surfaces, with a warning naming the
proxy; Sonarr and Radarr calling directly never send one. From a trusted proxy
every X-Forwarded-For line is read, in order, right to left past the other
trusted hops; a hop that is no address leaves the client unknown, and an unknown
client is refused by every allowlist rather than taken for the proxy.
Changes need this server’s own pages
Section titled “Changes need this server’s own pages”A request that changes something and rides on what a browser sends by itself —
the session cookie, or, on the native API under allowlist or open, the
address it calls from — must come from this server’s own pages. The browser’s
Sec-Fetch-Site says so (same-origin, or none for an address typed in);
an older browser’s Origin must be the name the request was addressed to (or a
trusted proxy’s X-Forwarded-Host), AMS_PUBLIC_URL, or one of
AMS_CORS_ORIGINS. Anything else is refused with 403 cross_site_request.
Clients that send neither header — Sonarr, Radarr, scripts, curl — and
anything authenticated by a key are not concerned.
TMDB clients
Section titled “TMDB clients”These do send an api_key, so in principle a key issued here works. Whether
you can use that depends on the client:
-
If it lets you set the TMDB API key — some do — put a key issued in the web UI there and leave
AMS_TMDB_AUTH=apikey. This server substitutes its own credentials before relaying upstream, so the client’s copy never reaches TMDB. -
If the key is compiled in — Jellyseerr’s is, as of this writing — there is nothing to configure, and the surface has to be guarded by address like the others:
Terminal window AMS_TMDB_AUTH=allowlist
Check before assuming: look for a TMDB API key field in the client’s settings.
What the relay writes into a title’s document (/3/movie/{id}, /3/tv/{id})
is what a person locked here, and nothing else: the title, the original
title, the overview, the homepage, the genres, the dates, the status, the
runtime, a chosen poster or background. A field nobody locked is TMDB’s own.
The text — the title, the overview, the homepage, the genres’ names — is
written only for a client that asks in the language the catalogue is kept in
(AMS_TMDB_LANGUAGE, by its language whatever the region: fr and fr-CA
are French; a client that asks none is answered TMDB’s en-US). A client
asking in another language gets TMDB’s text in it — except for a title or an
overview locked for that language alone, which is written into the documents in
that language. Genres keep TMDB’s ids:
when one of the genres locked is not among TMDB’s for that title, TMDB’s are
left as they are rather than sent without an id. Seasons, episodes and the
results of a search are written too, and a client’s own key can be handed on
to TMDB in place of this server’s: see The relays.
TMDB’s answers are kept a while and shared by every client — a search a
quarter of an hour, a title six hours, the configuration a day — and only
whole documents (200): a refusal, an error or a fragment is asked again.
TMDB is asked with the client’s User-Agent, Accept and Accept-Language
and nothing else it sent: no cookie, no key of this server’s, no Range, no
validator for an answer that is kept or patched. What mints or reads a TMDB
session — /3/authentication/…, /3/guest_session/…, /3/account/…,
anything asked with a session_id — is relayed but never kept.
TheTVDB and AniList clients
Section titled “TheTVDB and AniList clients”Some clients ask TheTVDB and AniList themselves, whatever they get from TMDB:
Yamtrack asks TheTVDB to place a Jellyfin or Plex episode it knows only by its
TheTVDB id, and AniList to import somebody’s lists. Resolve api4.thetvdb.com
and graphql.anilist.co to this server as well, and those calls come through
it: each is handed on to the real service, and its answer handed back with the
fields a person locked here written in — a series’ or a film’s title and
overview, its year, dates, status, runtime and chosen poster, an episode’s
title, overview, date, runtime and still; for AniList, the title, description,
genres and pictures of each entry the answer carries. Nothing unlocked is
touched: the services’ own data reaches the client as it is, in the language
it asked for.
TheTVDB clients sign in first, POST /v4/login with a key, for a token
they carry on every call after. Two ways in:
- A key issued here as the client’s TheTVDB key (
TVDB_APIin Yamtrack), withAMS_TVDB_AUTH=apikey, the default. The sign-in is answered with that same key as the token, so every call after carries a credential this server knows; TheTVDB is asked with this server’s own key (AMS_TVDB_API_KEY, the one its TheTVDB source uses) in the client’s place, and the client’s copy never reaches it. Without a key of its own, the relay answers those clients503. The documents answered with that key are the same for every caller, and are kept a while, as the TMDB relay keeps TMDB’s; the operator’s own account —user, its favourites — is nobody else’s, and refused with it. - The client’s own TheTVDB key, with
AMS_TVDB_AUTH=allowlist: the sign-in is relayed to TheTVDB as it came, and the token TheTVDB answers travels on every call after, as the client’s own. Nothing of a client’s own token is kept. A key issued here still stands in under this policy, and is judged as under the other: a key revoked, run out or a member’s is refused. Under the key policy, a client that brings its own TheTVDB token sends this server’s key inX-Api-Key, sinceAuthorizationis the token’s.
Reads only: TheTVDB’s one write, a user’s favourites, is refused whoever asks.
When TheTVDB refuses to sign this server in — a wrong or rotated key, a
missing PIN, TheTVDB down — the calls made with its key are answered 503 for
a minute rather than each signing in again, then two, up to ten while it
keeps refusing; the first refusal is in the log.
AniList has no key at all, so nothing a client sends can be one of this
server’s: AMS_ANILIST_AUTH=allowlist is the default, and a key issued here
would only serve a script of your own. A client reading somebody’s private
lists carries that person’s own AniList token, which travels with the query as
it came; an answer to a query that carried one, to a mutation, or about
somebody’s lists or account, is never kept — a public list is still
somebody’s, read for its latest state. An entry is recognised by AniList’s
id or by MyAnimeList’s, whichever the client asked for: Yamtrack’s import
asks only the latter. MyAnimeList numbers anime and manga apart, in numbers
that overlap, so an entry is matched by MyAnimeList’s id only when it says it
is an anime — its type, its format, or a count of episodes, a season or a
duration; Yamtrack’s manga list, and an anime still airing with no count of
episodes yet, are left as AniList answered them. AniList’s rate limit —
ninety queries a minute, thirty while it runs degraded — is handed back as
AniList answers it, 429 with Retry-After, and counts this server’s own
calls to AniList among them.
Both relays are switches on Opening & APIs (api.tvdb, api.anilist),
listed with the others; a member’s key is kept off every relay until Members
too is on there. Whose key the TMDB and TheTVDB relays ask with is a choice
there too, and what is written into their answers, and taken out of them, is in
The relays. Each is also a start-up flag, AMS_TVDB_PASSTHROUGH and
AMS_ANILIST_PASSTHROUGH, on by default. This server calls both services as
sources of its own, by the same names: see
If you redirect at the resolver.
Sonarr’s alternate titles
Section titled “Sonarr’s alternate titles”Sonarr recognises a release only by the series’ own title and the titles of
two lists it downloads every three hours: its own, from
services.sonarr.tv/v1/scenemapping, and TheXEM’s. It never reads the
alternative titles of a Skyhook answer — neither the real one’s nor this
server’s — so a release named in French, or in a romaji spelling nobody put
in those lists, is an unknown series however many titles this catalogue
holds.
Resolve services.sonarr.tv to this server as well, and turn on
sonarr.sceneMappings (Settings › Other metadata services, or
AMS_SONARR_SCENE_MAPPINGS=true as its starting value). Sonarr’s list is then
handed on whole, with a mapping added for each title of this catalogue that is:
- in a language releases of that series are named in: the language of the answers, English, or the work’s own original language, romanised. The other translations — thirty of them for a popular show — are names that releases of series outside this catalogue go by too, which no list here can tell, and an alternative title its source gave no language to is left out for the same reason;
- written in the Latin alphabet, as release names are;
- not already known to Sonarr for that series, compared the way Sonarr
compares titles (its
CleanSeriesTitle, ported and tested against its own cases), nor one of those titles followed by more words — a season, a special, a spin-off like Breaking Bad: Original Minisodes, whose releases are not the series’ episodes; - claimed by no other series in Sonarr’s list, in TheXEM’s names, or in this catalogue. A title two series answer to makes Sonarr throw for every release by that name, and in an interactive search that one failure empties the whole list, so such a title is left out rather than risked. A work hidden from the catalogue gains no title, and keeps its own from every other series.
Sonarr also searches indexers that cannot be searched by id with every title of
its list written in Latin-1, one query each. Of the titles added, only the
series’ title in the language of the answers is searched with
(sonarr.sceneMappingSearch, on by default); every other one serves to
recognise releases and is searched under the series’ own title, which Sonarr
searches anyway. A popular show’s thirty translations would otherwise be thirty
queries an episode.
Everything else Sonarr asks of that host — its updates, the list of daily
series, the server’s notices, the clock and proxy checks, the MyAnimeList
import’s sign-in — is relayed to the real service with the headers that
describe the request, never a key, a cookie or a proxy’s forwarding headers,
and its answer handed back as it came, a redirect included. A request that
carries this server’s own mark has come round a loop and is refused with a
508: set AMS_SONARR_SERVICES_UPSTREAM to the real service’s address when
the instances themselves resolve the name here — a resolver-level redirect, or
network aliases the instances share, as in compose.multi.yaml. With the setting off, the list is relayed as it is too. When the real
list cannot be had, the answer is a 502 and nothing else: Sonarr keeps the
list it holds, where this catalogue’s titles alone would have replaced it.
Sonarr shows the titles on the series’ page, with arr-metadata-server beside
each one it got from here; System › Tasks › Update Scene Mapping takes them
at once, and /api/v3/parse?title=… says which series a release name goes to.
Each mapping carries an identifier of its own, mappingId (ams- and
twenty-four hexadecimal digits, the same for a series and a title on every
answer). Sonarr 5 keeps its scene mappings by it — it updates the ones it holds,
deletes the ones no longer listed, and skips a mapping that has none, with a
warning in its log — where Sonarr 4 clears and fills its table and reads no such
field. The real service’s own are s- and twenty-four digits.
Upgrading a server that is already running
Section titled “Upgrading a server that is already running”The certificate of a server set up before a name existed — services.sonarr.tv
since 0.4.0, api4.thetvdb.com and graphql.anilist.co since 0.6.0 — does
not cover it, and cannot: the authority’s constraints were fixed when it was
made. Nothing changes until you choose to, in this order:
- Deploy the new version. Nothing else moves: the names added are listed under Opening & APIs as names the certificate does not carry, and the others are served as before.
- Copy the authority’s SHA-256 fingerprint from that page, set
AMS_TLS_REPLACE_AUTHORITYto it and restart (with several instances, restart them all: the first replaces it, the others take the new one). The log saysreplaced the authority; the old one is kept aside. - Make every client trust the new authority. A container that runs
trust-ca.shwithAMS_CA_URLtakes it when it restarts — updateAMS_CA_FINGERPRINTfirst where it is set. A mountedca.crt, as for Jellyseerr, is downloaded again; a Windows store imports it again. Until then, those clients refuse the certificate: do this step right after the previous one. - Remove
AMS_TLS_REPLACE_AUTHORITY. Left set, it does nothing — it names an authority that is no longer there — but it would replace this one too if the old pair were ever restored. - Add
services.sonarr.tvto Sonarr’sextra_hosts(or to the resolver) and recreate the container, then turnsonarr.sceneMappingson and run Update Scene Mapping in Sonarr. For the TheTVDB and AniList relays, addapi4.thetvdb.comandgraphql.anilist.coto the client’sextra_hoststhe same way, with its trust bundle in place (step 3).
To go back, turn the setting off: the list is relayed untouched, and Sonarr replaces the titles it got from here at its next update. Removing the redirect does the same. The old authority is kept aside, named after the first sixteen digits of its fingerprint, and put back with the server stopped:
- alone,
ca.crt.replaced-…andca.key.replaced-…inAMS_TLS_DIR, both copied back overca.crtandca.key; - with several instances,
tls.ca.replaced-…in thekeystoretable, its value copied overtls.ca’s:UPDATE keystore SET value = (SELECT value FROM keystore WHERE name = 'tls.ca.replaced-…') WHERE name = 'tls.ca'.
Every client then trusts the old authority again, as it did before step 3.
Episodes nobody has named yet reach Sonarr as TBA, as Skyhook sends them,
rather than as an empty title Sonarr lists as a row with nothing to click;
Sonarr rewrites the titles it holds at its next refresh of the series.
A Sonarr v5 asks for a series in the order a person chose for it — the aired
one, the DVDs’, straight through — and is answered in it when the catalogue
keeps that order for the series (the orders /api/v1/items/{id}/orders lists,
read from TheTVDB when the series is fetched); in the aired order otherwise,
as Skyhook answers, so that a choice the series has no order for fails
nothing. It asks in the language it holds for the series, too: see
Languages for what it is answered in.
Verifying it worked
Section titled “Verifying it worked”From inside a client container:
# Sonarrcurl -s https://skyhook.sonarr.tv/v1/tvdb/shows/en/81189 | head -c 200
# Radarrcurl -s https://api.radarr.video/v1/movie/329865 | head -c 200
# A TMDB clientcurl -s "https://api.themoviedb.org/3/tv/1396?api_key=<your ams_ key>" | head -c 200
# Sonarr's alternate titles: the added ones say where they come fromcurl -s https://services.sonarr.tv/v1/scenemapping | grep -c arr-metadata-server
# A TheTVDB client: signed in with a key issued here, the token is that keycurl -s https://api4.thetvdb.com/v4/login -H 'content-type: application/json' \ -d '{"apikey":"<your ams_ key>"}'curl -s https://api4.thetvdb.com/v4/series/81189 -H 'Authorization: Bearer <your ams_ key>' | head -c 200
# An AniList clientcurl -s https://graphql.anilist.co -H 'content-type: application/json' \ -d '{"query":"{ Media(id: 16498) { id title { english } } }"}'A TLS error means the CA is not trusted yet — check the container logged
[trust-ca] authority installed. A connection refused means the hostname override did
not take; check getent hosts skyhook.sonarr.tv inside the client. A 403 means
the client’s address is not on the allowlist — the Access page lists every
address that was turned away, with a button that allows it — or that it came
through a proxy missing from AMS_TRUSTED_PROXIES. A 401 means the surface
wants a key the client did not send.
The server’s own logs name which of those it was.
Verified against
Section titled “Verified against”The compatibility surfaces were exercised against real clients, not only against recorded responses:
| Client | Version | What was checked |
|---|---|---|
| Sonarr | 4.x (linuxserver) | lookup, add, 71 episodes imported, refresh picking up a locked title, network and episode title |
| Sonarr | 5.0.0.2635 (linuxserver develop-version-5.0.0.2635) |
lookup by text, tvdb:, tmdb:, imdb:; add with French as the metadata language (title, overview and the episode titles in French), in the DVD order, with the seasons’ own titles; refresh picking up a locked title; a release named by a title added here taken for its series |
| Radarr | 6.4 | lookup, add, 26 credits stored, certification, refresh picking up a locked title, studio and runtime; 6.4.4: collection, recommendations |
| Plex Media Server | 1.43.4 (plexinc/pms-docker) |
the provider added over Plex’s own API, an agent made of it, libraries of films and series, the scan: the film, the series, its season and its episodes with the catalogue’s titles, summaries and pictures |
| Jellyseerr | latest | /3/movie, /3/tv, /3/search/movie and /3/configuration relayed, with local overrides applied |
| Yamtrack | latest | signed in to TheTVDB with a key issued here through its own provider, read a series and an episode with the titles locked here written in, placed the series by its TMDB id, queried AniList through its request helper with the locked title written in — all by the services’ names, in TLS, trusting the authority |
That run turned up several things this document had wrong, and several the server had wrong — see the commit history.
scripts/e2e-sonarr.sh repeats the Sonarr part
on demand: it starts the server with a catalogue of its own and both doors,
starts a Sonarr container that resolves skyhook.sonarr.tv to this host and
installs the authority through trust-ca.sh, then has Sonarr search, add the
series, and fetch its episodes and its poster from here. It needs Docker,
python3, a built binary and a free port 443 — which on Linux a user binds only
after setcap cap_net_bind_service=+ep on the binary.
scripts/e2e-sonarr-parity.sh compares:
two Sonarrs are given the same real series, one reaching this server for
skyhook.sonarr.tv and services.sonarr.tv, the other the real services, and
what they hold is set side by side — the series, every episode, TBA included,
the alternate titles, and the series each takes a set of release names for.
A difference is either an addition of this server’s, or a failure. It reaches
the internet, and wants TMDB_API_KEY (and TVDB_API_KEY) in the environment.
scripts/e2e-relays.sh exercises the TheTVDB and
AniList relays against a stand-in for both services
(scripts/e2e-relays-upstream.py) that
records what reaches it: the sign-in with a key issued here and with a
client’s own, which token travels and which never does, the locks written
into a series, its translation, its episodes and an AniList entry, the cache,
AniList’s rate limit handed back, a loop refused, a relay switched off — under
the default policies and with them swapped. It reaches nothing outside this
host, and needs a built binary and python3.
scripts/e2e-yamtrack.sh repeats the Yamtrack
row of the table above on demand: it starts the server with both doors and
the real services behind the relays, starts a Yamtrack container that resolves
api4.thetvdb.com and graphql.anilist.co to this host with the authority
appended to its trust bundle, and runs Yamtrack’s own TheTVDB provider and
request helper inside it. It needs Docker, a built binary, a free port 443,
the internet, and this server’s TheTVDB and TMDB keys in the environment.
scripts/e2e-stack.sh runs all of it at once, with
the real software and no stand-in: the server from the tree’s binary on a
catalogue of its own, and in Docker
(compose.e2e-stack.yaml) Sonarr 4, Sonarr 5, Radarr
and a Plex Media Server, each resolving skyhook.sonarr.tv,
services.sonarr.tv and api.radarr.video to this host and trusting the
authority. It searches and adds real series and films and compares
what the clients hold with what the server serves; adds a series in French and in
its DVD order on Sonarr 5; locks a title and finds it in each client after a
refresh; connects the server to them and finds the command a lock sends in their
history of commands; seeds a new, empty catalogue from their libraries; and has
Plex add this server as a metadata provider, make an agent of it, scan a film and
a series and show what the catalogue holds — then, the server asking for a key,
gives Plex the address that carries one (/plex/<key>)
and sees a lock reach it there. PHASES="sonarr5" runs a part of it,
KEEP=1 leaves the containers behind. It needs Docker, python3, a built binary
(BUILD=1 builds it), a free port 443, the internet, and TMDB_API_KEY and
TVDB_API_KEY (read from the environment or from .env.local, and handed to the
server alone). radarr.servarr.com, Radarr’s own services host (its updates),
carries no metadata and is left alone, as it should be in a deployment.
Plex’s own API needs no token on a server nobody has claimed, and the script uses
none: the provider, the agent and the libraries are made as a user makes them in
Settings › Metadata Agents. Against a server that is claimed, which refuses a call
without a token from a network it does not allow without authorization, the plex
checks say they were skipped and what to allow: Settings › Network › List of IP
addresses and networks that are allowed without auth (or PLEX_ALLOWED_NETWORKS
at the container’s first start; PLEX_CLAIM, a token from
https://plex.tv/claim, claims the server as the user’s own at that same first
start).
If you redirect at the resolver
Section titled “If you redirect at the resolver”Everything above redirects per container, with extra_hosts. If instead you add
the records to your local DNS resolver, this server resolves them too — and
it calls skyhook.sonarr.tv, api.radarr.video, api.themoviedb.org,
api4.thetvdb.com and graphql.anilist.co itself, because they are
providers as well as protocols it speaks.
It recognises the loop and answers 508 Loop Detected rather than recursing, so
nothing hangs. But the enrichment is then dead — and so are the TMDB, TheTVDB
and AniList relays, which ask the real services. Either point the upstreams at
the real services by address:
AMS_SKYHOOK_UPSTREAM=https://<real-skyhook-address>AMS_RADARR_METADATA_UPSTREAM=https://<real-api.radarr.video-address>AMS_SONARR_SERVICES_UPSTREAM=https://<real-services.sonarr.tv-address>AMS_TMDB_UPSTREAM=https://<real-api.themoviedb.org-address>AMS_TVDB_UPSTREAM=https://<real-api4.thetvdb.com-address>/v4AMS_ANILIST_UPSTREAM=https://<real-graphql.anilist.co-address>or turn enrichment off:
AMS_SKYHOOK_ENRICH=falseAMS_RADARR_METADATA_ENRICH=falseAn upstream named this way may be anywhere — a mirror on the same machine or the same network included: it is yours. Where it redirects is not: a hop to an address that only means something from where this server stands (loopback, link-local, a cloud’s metadata service), written as an address or as a name that resolves there, is refused, and MyAnimeList’s client id is never carried to another origin — scheme, host and port — than the one first asked.
When a provider does not answer
Section titled “When a provider does not answer”A provider that fails during a refresh — an error, a timeout, a rate limit,
a queue that is full — does not make the work lose what it gave before. Its
values, pictures, translations and ratings are put back from what the work
holds; the episode list keeps TheTVDB’s numbering when TheTVDB (or Skyhook)
is the one that failed; a season TMDB could not give keeps its stored
episodes, so Sonarr is never told they are gone; and the adult flag stands
when AniList or MyAnimeList, which often alone set it, did not answer. The
work is then marked as refreshed in part: its refresh error, which editors
see, names the providers that failed, and it is tried again within six
hours, or sooner near an air date, rather than a full interval later.
A supplement that fails — TVmaze, Fanart.tv, AniList, MyAnimeList, the
Fankai wiki — has what it gave before put back all the same, but leaves
the work refreshed: no refresh error, no refresh.failed. The log says
which did not answer.
A provider that answers 429 — or 503 with a Retry-After — is left
alone for as long as it asks, a minute at most, by every request at once;
one that asks for longer is gone without until then. TMDB is asked at most
sixteen requests at a time, TheTVDB, Skyhook, Radarr’s service and
Fanart.tv eight, the sources spaced by a queue four or fewer. A TheTVDB key
that is refused is not tried again for a minute.
A note on languages
Section titled “A note on languages”Sonarr v4’s request builder pins the language segment to en and nothing in
Sonarr changes it, so asking this server for French through Sonarr v4 is not
possible from Sonarr’s side. Set AMS_TMDB_LANGUAGE instead: entries are stored
in that language and every client gets it. Sonarr v5 does ask in the language set
for the series: see Languages.
Radarr has no language in its protocol at all, and the same applies.
TMDB clients do send language=, and it is forwarded upstream, so those get
whatever they ask for. Your locked text — a title, an overview — is written
into what they asked for only when they asked in AMS_TMDB_LANGUAGE’s
language, which it was written in, or in the one language it was locked for
alone (Editing); a lock that is no
text, a date or a chosen poster, is written in every language.
Series of the same name
Section titled “Series of the same name”TheTVDB names the second of two homonymous series with what tells it apart — Rurouni Kenshin (2023), The Office (US) — and Skyhook passes that on, so it is the title Sonarr files, names folders after and matches releases by. TMDB never does, and its name is usually the title here. The Sonarr surface therefore gives back what TheTVDB adds, and only there: the site, the native API and the TMDB relay keep the title as TMDB names it, the year shown beside it. A work TheTVDB has no entry for is given its year when another series of the catalogue is served the same title, since two series Sonarr knows by one title make its lookup by title fail. A locked title is sent exactly as it was locked.
What about Plex?
Section titled “What about Plex?”Plex’s own metadata comes from metadata.provider.plex.tv, over a pinned
connection tied to your Plex account, and that cannot be substituted the way the
others’ addresses can. But Plex Media Server 1.43 and later can be given another
provider by its address, and this server is one: add /plex on the interface’s
door under Settings › Metadata Agents. That is the route to take for a Plex that
can call this server; see Plex.
The other routes are for a Plex that cannot, and for Kodi and Jellyfin. One is
the Personal Media / XBMCnfo agent reading .nfo files and local artwork.
Sonarr and Radarr already write those, so a practical setup is: this server feeds
Sonarr and Radarr, they write the .nfo files, and Plex reads them. Your
corrections reach Plex, just indirectly. The other is Kometa, which can lay what
is locked here over a library’s metadata. Both are in
Exports.
If nothing else manages the library, POST /api/v1/export/nfo writes the whole
catalogue under AMS_NFO_EXPORT_PATH, pictures included:
series/breaking-bad-2008/tvshow.nfoseries/breaking-bad-2008/poster.jpg fanart.jpg banner.jpg clearlogo.pngseries/breaking-bad-2008/season01-poster.jpgseries/breaking-bad-2008/.actors/Bryan Cranston.jpgseries/naruto-shippuden-yabai-2007/theme.mp3 where the provider keeps a themeseries/breaking-bad-2008/Season 01/S01E01.nfoseries/breaking-bad-2008/Season 01/S01E01-thumb.jpgmovies/arrival-2016/movie.nfo poster.jpg fanart.jpg …Those are the names Kodi defined and Plex’s agent adopted, which is the point:
a .nfo that only carries image URLs leaves the fetching to the consumer, and
Plex often will not. Copy or link this tree next to your media. Re-running the
export rewrites the documents and leaves existing pictures alone, so it is cheap
to repeat. Set AMS_NFO_EXPORT_ARTWORK=false for documents only.
The pictures are fetched as the media store fetches them, since the addresses
are whatever someone who may edit put on a work: never from this machine, a
cloud’s metadata service or another address only the server can reach, every
redirect judged on its way, never through HTTP_PROXY, and a file written only
when it is the picture or the sound it should be. A picture mirror on your own
network is reached; AMS_MEDIA_PRIVATE_NETWORKS=false keeps the export and the
media store out of private networks (10/8, 172.16/12, 192.168/16,
100.64/10, and IPv6’s unique-local fc00::/7) too.
The call answers at once, 202 with the job it started, and the export is
written in the background: a library’s artwork is thousands of downloads,
more than a request can wait for. The run is listed under Jobs as
export.nfo, with what it wrote once it is done; a second export asked for
while one is running is refused with 409.
A Fan-Kai comes out the same way once the Fankai source is on and the
production has been imported or asked for by Sonarr: series/{slug}/tvshow.nfo
with <uniqueid type="fankai">, and one document per film under the season
its saga is, numbered as Fankai names the files — Season 01/S01E02.nfo beside
Horimiya Kaï.S01E02.MULTI.1080p.x265-FANKAI.mkv. Plex reads it through the
same agent; Jellyfin and Kodi read it as they read any .nfo. Its theme music
lands beside it as theme.mp3, the name Plex’s local assets, Jellyfin and
Kodi’s theme add-ons play as the show’s theme.
Jellyfin and Emby are a different matter — both accept metadata plugins, so a direct provider for them is possible. It is not built yet.