The original Nalanda was one of the ancient world's great libraries. This one is less ambitious: it keeps your Jellyfin library tidy.
Describe the collections you want in one YAML file and Nalanda builds and maintains them as Jellyfin BoxSets of movies and TV shows, optionally syncing with Radarr (movies) and Sonarr (TV). It is a simplified collection and metadata manager for Jellyfin, inspired by Kometa.
Re-run it as your sources change, or on a schedule, and your collections follow: a "Trending This Week" list re-trends, a franchise gains its latest film.
- Builders. Build a collection from TMDB, TVDB, or MDBList: whole franchises and curated lists, everything in a genre or tagged with a keyword, a person's cast or crew credits, a studio's or network's catalogue, popularity and trending charts, or a custom query of your own. Mix sources together, require or exclude specific attributes, and sequence the groups in the order you want. Names or ids both work.
- Movies, TV, and mixed. Each collection is movies, TV, or both. Every source works for either, and a mixed collection gathers films and series into one BoxSet (a whole franchise in a single place), syncing with both Radarr and Sonarr.
- Jellyfin collections. Nalanda builds each collection as a Jellyfin BoxSet and keeps its membership, description, artwork, and ordering in step with your config. Artwork is pulled from TMDB automatically, or you can supply your own poster, thumbnail, and backdrop. Related collections can be grouped together in the library view.
- Per-item metadata. Write and lock specific fields (parental rating, title, overview, sort title) on individual movies and shows in Jellyfin, independent of any collection.
- Radarr and Sonarr (optional). Hand a collection's titles to Radarr or Sonarr: add the ones you don't have yet, line up their quality profile and monitoring, and tag each title so a collection's members stay identifiable. Nalanda only ever touches its own tags, never yours.
- Webhook daemon and scheduling (optional). Run Nalanda as a background service that rebuilds on a schedule, or re-syncs the moment Radarr or Sonarr signals a change, touching only the collections affected.
- Metadata cache. A disposable on-disk cache of what TMDB, TVDB, and MDBList return, so repeated runs don't re-fetch the same data. Safe to delete; it never affects correctness.
- Editor validation. A JSON Schema generated from the code validates
config.ymlas you type, so typos are caught immediately.
- A Jellyfin server and an API key
- To run Nalanda: Docker, or Python 3.14+ and uv for a local install from source
- Optional: Radarr (movies) and Sonarr (TV) for syncing and tagging; an MDBList API key
for
mdblist_*builders; a TMDB API read token for anytmdb_*builder. TVDB needs no key, because Nalanda ships a per-project one, sotvdb_*builders work out of the box.
Nalanda needs two files: .env for secrets and connection details (gitignored), and
config.yml for your collections. Deploy with Docker, or run it locally from source with uv.
Images are published to the GitHub Container Registry at ghcr.io/code-boar/nalanda.
latest tracks the newest release; each release is also tagged X.Y.Z (plus X.Y and
X), so pin a version for reproducible deploys.
The image is rootless: it runs as a baked non-root user (uid/gid 1000), with no
privilege-dropping entrypoint. Everything Nalanda persists lives in one mounted directory,
/config (holding config.yml, the state file, and the co-located .env), so the rest of the
container filesystem can be read-only. Start from the bundled compose file:
cp docker/compose.example.yml docker-compose.yml # then edit to taste
docker compose up -dFirst run. With an empty ./config, Nalanda seeds a starter config.yml and .env into
it, then comes up idle and healthy -- it does nothing useful yet, but it does not crash-loop.
Edit the two seeded files, ./config/config.yml (your collections) and ./config/.env (your
Jellyfin URL and API key, plus any Radarr/Sonarr/MDBList keys), then docker compose restart.
Any later change to those files also needs a restart: .env is read once at startup, and the
config's schedules and known-collection set are wired at startup too.
Ownership. Because nothing in the container runs as root, it cannot fix the mount's
ownership for you. ./config must be writable by the uid the container runs as. With the
default uid 1000, bind-mounting a fresh host folder usually means a one-time
chown 1000:1000 ./config. To run as a different uid, set user: in the compose file (e.g.
user: "${DOCKER_UID}:${DOCKER_GID}"); PUID/PGID environment variables do nothing here,
as there is no entrypoint that reads them.
Secrets. Keep them in ./config/.env (a file Nalanda reads), not -e environment
variables. A file is not exposed in docker inspect or the host process environment, and
Nalanda loads it into memory without re-exporting it, so the keys do not leak through the
container's own environment either. The seeded .env is created owner-only (0600); if you
supply your own, run chmod 600 ./config/.env to match.
Runtime. The image runs the daemon under tini as PID 1 for a clean shutdown, bundles a
healthcheck that calls its own /health, and includes tzdata so setting TZ makes the cron
fire on your local time.
Networking. If Radarr/Sonarr run in the same Docker network, point their webhooks at
http://nalanda:8842/trigger (no published port needed); the image sets NALANDA_HOST=0.0.0.0
so a published port is reachable. See Network exposure for publishing to
the LAN -- keep the endpoint on a trusted network behind a TLS reverse proxy, since it is plain
HTTP with a header secret.
uv sync # install dependencies
cp .env.example .env # add your TMDB / Jellyfin (and optional Radarr/Sonarr/MDBList) keys
cp config.example.yml config.yml # define your collections
uv run python -m nalanda schema # optional: regenerate config.schema.json for editor validationconfig.example.yml is a worked example you can copy as a starting point; the
configuration reference below documents every option.
These examples use the local (uv) install. With Docker the container already runs serve; to
invoke any other subcommand, run it inside the container without the uv run prefix, e.g.
docker compose exec nalanda python -m nalanda run collections "A Movie Franchise".
# Build every collection in config.yml, or just the named ones
uv run python -m nalanda run # run everything (collections, then metadata)
uv run python -m nalanda run collections "A Movie Franchise" "Some Favourite Shows"
uv run python -m nalanda run metadata # apply per-item metadata overrides only
uv run python -m nalanda run --dry-run # preview; logs every intended write, mutates nothing
# Build a single collection from one source (a TMDB collection id or MDBList URL)
uv run python -m nalanda build "A Movie Franchise" 1234
# Run the webhook daemon
uv run python -m nalanda serve
# Apply per-item metadata overrides on demand (also runs on a schedule under serve)
uv run python -m nalanda run metadata
# Inspect or maintain the metadata cache
uv run python -m nalanda cache info
# Regenerate the JSON schema after upgrading
uv run python -m nalanda schemaA bare uv run python -m nalanda <tmdb-id | mdblist-url | tvdb:ref> prints a read-only report:
what the source resolves to and its status in Jellyfin and Radarr/Sonarr. Use it to inspect a
source before adding it.
For each collection in config.yml (media: movie, tv, or mixed):
- Build — run the builders to get an ordered set of titles, each with its TMDB, TVDB, and IMDb ids.
- Match — find which titles already exist in the Jellyfin library.
- Sync Radarr/Sonarr (when a
radarr:orsonarr:block hasenable: true) — add any missing titles and reconcile the identity tag, quality profile, and monitoring on the rest. - Assemble — create or reconcile the Jellyfin BoxSet: membership, display order, overview, images, and sort title, writing only what differs from the server.
A small state file (.nalanda-state.json) records the image last applied to each slot (a URL,
or a content hash for a local file, which Jellyfin can't read back), so an image is re-applied
only when it changes.
config.yml defines your collections; .env holds secrets and connection details. Nalanda's
builders are named per-attribute keys that accept names or ids, with sensible defaults; the
tmdb_discover escape hatch still takes raw TMDB parameters for the rare compound query.
A JSON Schema is generated from the code (nalanda/config.py), so editor validation can't
drift from what Nalanda accepts. The starter config already references it, and a YAML-aware
editor (for example VS Code with the Red Hat YAML extension) validates as you type:
# yaml-language-server: $schema=./config.schema.jsonThe schema is read only by your editor; Nalanda validates the config itself when it loads,
so this is purely an authoring aid. It is generated from the version you run rather than
fetched from a URL, so it can't get ahead of the code that actually enforces it. Every
run and serve writes config.schema.json next to your config.yml and refreshes it,
so it stays matched across upgrades, in Docker and from a checkout alike. There is also a
one-off command if you want to regenerate it by hand:
uv run python -m nalanda schema # writes ./config.schema.jsonUnknown keys are always rejected, so typos surface immediately, and everything documented here is live.
There are three separate "combine" decisions, each with its own operator. Keeping them distinct is what keeps the config readable.
| Level | Decision | Operator |
|---|---|---|
| 1. within a key | how multiple values of one attribute combine | all / any / except |
| 2. within a block | how different keys combine | match: all | any |
| 3. within a collection | how separate groups are sequenced | append: |
Past these three levels, reach for an escape hatch rather than deeper nesting:
- an arbitrary server-side query:
tmdb_discover: - an arbitrary hand-picked set:
tmdb_movie:/tmdb_show:/tmdb_title:lists
There is no append inside append, and no recursive except blocks.
A source key produces titles. Multi-valued keys (a title has many genres, a person has many
credits) accept an {all|any|except} mapping; single-identity keys do not.
| Key | Value | all/any |
except |
Order |
|---|---|---|---|---|
tmdb_collection |
id | list | — | — | chronological |
tmdb_movie |
id | list | — | — | chronological |
tmdb_title |
"Title" | "Title (YYYY)" | list | — | — | chronological |
tmdb_keyword |
id | name | list | {all|any|except} |
yes | yes | chronological |
tmdb_genre |
id | name | list | {all|any|except} |
yes | yes | chronological |
tmdb_list |
id | list | {all|any|except} |
yes | yes | curated |
mdblist_list |
url/ref | list | {url|all|except, sort_by, limit} |
yes¹ | yes | server (sort_by) |
mdblist_catalog |
{sort, score_min, genre, year_min, …, limit} |
n/a | n/a | sort |
mdblist_official |
slug | {slug, sort_by, limit} |
— | — | server |
tmdb_company |
id | name | list | {all|any|except} |
yes | yes | chronological |
tmdb_actor / tmdb_director / tmdb_writer / tmdb_producer / tmdb_crew |
id | name | list | {all|any|except} |
yes | yes | chronological |
tmdb_popular / tmdb_trending_daily / tmdb_trending_weekly / tmdb_top_rated / tmdb_upcoming / tmdb_now_playing |
count (int) | — | — | chart order |
tmdb_discover |
raw Discover dict (escape hatch) | n/a | n/a | sort_by |
¹ mdblist_list uses url (union) rather than any; all intersects lists, except
subtracts. MDBList items are enriched (genres mapped to TMDB ids, so tmdb_genre: {except}
and match: all work on them; plus full release dates and cross-source ratings).
mdblist_list sorts and limits server-side with a rich vocabulary
(imdbrating / rtomatoes / metacritic / rank / …); mdblist_catalog filters and sorts
MDBList's whole database by those same cross-source ratings.
Names and ids are interchangeable everywhere a name is accepted, and may be mixed
(all: [28, Adventure]). Names resolve against TMDB (the genre list, or keyword / person /
company search). Genre names are case- and punctuation-insensitive but must use TMDB's
canonical spelling (Science Fiction, not Sci-Fi). Person and company names resolve to the
most-popular match (for companies, the studio with the most titles); use an id to
disambiguate. People builders use credits: tmdb_director / writer / producer filter crew
by department. A chart's value is the count of titles to take, for example tmdb_popular: 30.
Ordering within a block. A sole curated source (exactly one tmdb_list, mdblist_list,
chart, or tmdb_discover, and nothing chronological) keeps its server order. Any other
combination is unified and sorted by release date, so mixed sources interleave
chronologically. This same distinction sets the default order: when you don't specify one:
a release-sorted block defaults to release_date, a sole curated source to source.
# single source
Westerns: { tmdb_genre: Western }
Films by a Director: { tmdb_director: A Director Name }
From One Studio: { tmdb_company: A Studio Name }
# per-key value combine
Action-Adventure:
tmdb_genre: { all: [Action, Adventure] } # films that are BOTH (AND)
Either Actor:
tmdb_actor: [First Actor, Second Actor] # a list = union of both actors' films
Both Actors:
tmdb_actor: { all: [First Actor, Second Actor] } # intersection: films with both
# per-key exclude (a filter, not a source; can stand alone)
A Keyword, minus the making-of docs:
tmdb_keyword: 1234
tmdb_genre: { except: [Documentary] }By default, multiple keys in one block are unioned (match: any). Set match: all to require
every key, the compound intersection case (release-sorted).
Sci-Fi with an Actor:
match: all
tmdb_genre: Science Fiction
tmdb_actor: An Actor Name
limit: 300 # cap the broad genre fetch (intersect their films with the top 300)For compound queries using parameters Nalanda doesn't wrap, drop to the escape hatch:
Highest-Grossing R Comedies:
tmdb_discover: { with_genres: 1234, certification: R, sort_by: revenue.desc }Each block is resolved independently (levels 1–2), then blocks are concatenated in the order listed, so you control cross-source ordering.
Saga Marathon:
tmdb_list: 1234 # block 0: curated order
append:
- tmdb_collection: 1234 # block 1: chronological
- tmdb_title: [Example Film] # block 2These apply to the whole collection, orthogonal to the builders above.
| Key | Description |
|---|---|
media |
Required (no default). movie | tv | mixed. Selects the Jellyfin library type(s), builder dialect, and arr(s). mixed = one BoxSet of movies and shows; see Mixed collections. |
overview |
Overview text; overrides any source metadata. |
tmdb_overview |
A TMDB collection id whose overview is fetched at runtime (useful for merges, which have no sole source to auto-fill from). A literal overview overrides it. |
primary_art_url / primary_art_file |
Primary (poster) image: a remote URL Jellyfin downloads, or a path to a local file whose bytes are uploaded. Overrides the artwork repo and the auto-sourced TMDB poster. The two are mutually exclusive. |
thumb_art_url / thumb_art_file |
Thumb (landscape title card) image: URL or local file. Overrides the artwork repo and the auto-sourced TMDB titled backdrop. Mutually exclusive. |
backdrop_art_url / backdrop_art_file |
Backdrop (fanart) image: URL or local file. Overrides the artwork repo and the auto-sourced TMDB textless backdrop. Mutually exclusive. |
sort_title |
Explicit sort title for positioning the collection itself (see Sections). |
section |
Name of a top-level sections entry (see Sections). |
order |
source (as built) | sort_name | release_date. Unset = a smart default: release_date when the collection is release-sorted at build (single/merged collections, query builders), else source (a sole curated list keeps its server order). |
sync_mode |
append (add only) | sync (also remove). Overrides the global default. |
hide_year |
Empty the collection's year and release-date fields. Overrides the global hide_year (default true). |
run_schedule |
Per-collection cron override for nalanda serve (level 3 of the cascade): a name from settings.run_schedules, an inline cron, or none / disabled to opt out. See Scheduling. |
libraries |
Jellyfin libraries (by name) to match within; omit for all libraries of the collection's media type. |
limit |
Cap each broad query builder (genre/keyword/company/people/discover) most-popular-first. Defaults to 100; set 0 for unlimited. |
radarr |
Radarr sync and identity-tag options (see Radarr integration). For movie or mixed collections; set enable: true to opt in; fields inherit settings.radarr. |
sonarr |
Sonarr sync and identity-tag options (see Sonarr integration). For tv or mixed collections; inherits settings.sonarr. |
Global defaults live under settings:: sync_mode; language / region (TMDB text metadata
and release dates, see Language & region); hide_year (default true,
empties every collection's year/release-date fields, per-collection overridable);
delete_unconfigured_collections (default false: on a full run, delete collections Nalanda
previously made that are no longer in the config, never touching BoxSets it didn't create);
unlock_unconfigured_metadata (default false: when a metadata: entry or field is removed
from config, unlock it in Jellyfin so a refresh can reclaim it; see
Per-item metadata);
radarr / sonarr (default options inherited by every radarr: / sonarr: block);
webhook (the inbound-request behaviour of the serve daemon);
and run_schedules / run_schedule / jobs (the cron Scheduling cascade the
daemon runs on; jobs.metadata sets the per-run-kind default for the metadata job).
language and region are two different axes, a common point of confusion because a locale
like en-GB already contains a region:
| Setting | Format | What it controls |
|---|---|---|
language |
TMDB language tag: ISO 639-1, optionally with a region (en, en-US, en-GB, fr-FR, pt-BR) |
The language of TMDB text metadata: titles, overviews, genre names. The region suffix only nudges regional text variants (pt-BR vs pt-PT; US vs GB spellings). Default en-US. |
region |
ISO 3166-1 (US, GB) |
Release dates only, on chart/discover builders. Independent of language's region suffix. |
Put the language you want your overviews and titles in as language (most people leave it at
en-US, or use en-GB / en-AU for local spellings). Set region only if you care about
region-correct release dates on charts and discover.
Two things to know:
languagedoes not affect artwork. Image selection uses only the language subtag (enout ofen-GB) plus textless art, soen-GBvsen-USchanges the text but never which poster or backdrop is chosen.- TVDB metadata is always English today, regardless of
language:tvdb_*builders resolve under a fixedeng/usa. Solanguagecurrently reaches TMDB only.
Jellyfin sorts collections (BoxSets) by name; the only lever for custom ordering is a
collection's sort title. Setting one by hand on every collection is tedious, so Nalanda adds
sections: a top-level, ordered list of section names. A collection's section gives it an
auto-generated sort title prefixed by the section's position, so sections group together in
that order in the library view.
sections: # the order here is the order sections appear in Jellyfin
- Charts
- Universes
- Studios
collections:
Top 100: { section: Charts, tmdb_list: 1234 } # → sort title "001 …"
Movie Franchise: { section: Universes, tmdb_collection: 1234 } # → "002 movie franchise"
From One Studio: { section: Studios, tmdb_company: A Studio Name } # → "003 from one studio"How the sort title is derived (the sort_title key composes with section):
section |
sort_title |
Resulting sort title |
|---|---|---|
| — | — | none (Jellyfin's native name sort) |
| — | set | sort_title verbatim (manual override) |
| set | — | <NNN> <normalized name> |
| set | set | <NNN> <sort_title> (section grouping plus custom within-section order) |
The normalized name replicates Jellyfin's own sort-name rules (ported from
BaseItem.CreateSortName, read live from /System/Configuration): lower-cased, with the
article words (the / a / an by default) stripped at the start, middle, and end, and the
configured punctuation removed or replaced. So "The Best of the Year" becomes
best of year, matching how Jellyfin sorts everything else.
- Nalanda sets the BoxSet's
ForcedSortName. Jellyfin zero-pads digit runs internally, so the stored value001 movie franchisesorts as0000000001 movie franchise. - Within a section, collections sort alphabetically by default; add a short
sort_title("01","02") to force a custom order. - Collections without a
sectionkeep Jellyfin's native sort and fall after the sectioned ones (numbers sort before letters). - A collection's
sectionmust be a declaredsectionsentry, or the config is rejected.
Nalanda owns every collection's overview and images. It sets them from the data it pulled that
run, locks Name / Overview (and sets LockData) so Jellyfin's own provider can't
overwrite them, and reconciles by comparison, writing only when the desired value differs from
what's on the server (images are compared against a saved marker, since Jellyfin doesn't
expose the image source). Nothing is delegated to Jellyfin's fetch.
What differs between collections is where the defaults come from, and whether the TMDB id is stamped:
- A collection that is exactly one
tmdb_collection(no merge, no list) has that collection id stamped as its provider id, and its overview and images default to that collection's TMDB metadata, so you needn't supply them. Anoverview/tmdb_overviewor any per-slot art override still wins. - A merge, list, keyword, or other source has no sole source to draw from, so its provider id
is left blank (
{}) and its overview and images default to empty. Give it anoverview(literal text) ortmdb_overview(a collection id whose overview is fetched at runtime), and per-slot art (URL, local file, or an artwork repo file).
The three Jellyfin image slots map to TMDB the way Jellyfin's own provider does: a backdrop
with a language tag (it has title text) is the Thumb, a textless backdrop is the Backdrop,
and a poster is the Primary. For a single tmdb_collection all three are auto-sourced; among
candidates the preferred metadata language (settings.language) wins, then English, then
community rating, matching what Jellyfin would download. Any slot Nalanda doesn't set is
cleared, so a collection shows only its own images.
Each slot is resolved independently, in this priority order:
- an explicit per-slot override:
<slot>_art_url(a remote URL Jellyfin downloads) or<slot>_art_file(a path to a local file whose bytes Nalanda uploads); - a matching file in the artwork repository;
- the auto-sourced TMDB image;
- nothing (the slot is left clear).
<slot>_art_url and <slot>_art_file are mutually exclusive; set at most one per slot
(setting both is a config error). If an explicit override is invalid (a malformed URL, or a
file that doesn't exist), Nalanda logs a warning and falls through to the repo, then TMDB; a
simply-absent override is silent. Change detection works for both kinds: a URL is compared by
its string, a local file by a hash of its bytes, so editing a file (even under the same path)
re-uploads it, while an unchanged run leaves it alone.
Instead of (or alongside) per-collection keys, you can drop image files into a local artwork repository and Nalanda picks them up automatically. Point it at a root, and for each collection it looks for:
<path>/collections/<slug>/<type>.<ext>
<type>isprimary,thumb, orbackdrop(the same vocabulary as the keys).<ext>ispng,jpg,jpeg, orwebp. If several exist for one slot, precedence ispng>jpg/jpeg>webp, and the losers are ignored with a warning.<slug>is the collection name slugified: lowercased, with runs of non-alphanumeric characters collapsed to a single-. So"A Title: Subtitle"→a-title-subtitle. This is the same slug Nalanda uses for Radarr/Sonarr identity tags, so a collection has one identity on disk and in the*arrs. When an artwork repo is configured, two collection names that slugify to the same folder are a config error.
Configure it under a top-level artwork_repo: block:
artwork_repo:
path: /artwork # repo root; images live under <path>/collections/<slug>/
create_empty_folders: false # create an empty folder per collection to drop art into
delete_old_empty_folders: false # on a full run, remove empty folders no longer configuredcreate_empty_folders makes the drop-folders for you, so you never have to guess the slug.
delete_old_empty_folders tidies up on a full run: it removes a collections/<slug>/ folder
only when its collection is no longer configured and the folder is empty. A folder containing
any file is never touched.
Add a radarr: block with enable: true to a movie (or mixed) collection to manage it in
Radarr. enable defaults to false, so a block without it is inert: configuration is kept but
nothing is added, tagged, or searched (handy as an off switch). Every other field is optional
and inherits settings.radarr; radarr: { enable: true } opts in on all defaults.
collections:
A Synced Movie List:
media: movie
mdblist_list: https://mdblist.com/lists/someuser/some-list
sync_mode: sync
radarr:
enable: true # opt in (omit or false = inert)
add_missing: true # add movies not yet in Radarr (tagged on add)
add_existing: true # tag movies already in Radarr
quality_profile: HD-1080p # name or id (required when adding/upgrading)
root_folder: /movies
search: true # search for newly added movies onlyNalanda owns one identity tag per collection, <tag_prefix><slug> (default nalanda-<slug>,
for example nalanda-movie-franchise), and keeps exactly the desired members tagged. The
tag_prefix is a namespace: Nalanda only ever adds or removes tags inside it, never touching
manual or other-tool tags. This tag is also the routing key for the
webhook.
| Key | Default | Description |
|---|---|---|
enable |
false |
Opt-in switch. true = manage this collection in Radarr; false or omitted = inert. |
add_missing |
false |
Add collection movies not yet in Radarr (tagged on add). |
add_existing |
false |
Apply the identity tag to movies already in Radarr. |
upgrade_existing |
false |
Reset existing members' quality profile to quality_profile (diff-first). |
monitor_existing |
false |
Reset existing members' monitored flag to monitored. |
monitored |
true |
Monitor newly added movies. |
search |
false |
Search for newly added movies (never existing ones). |
minimum_availability |
released |
announced | in_cinemas | released. |
quality_profile |
— | Profile name or id. Required when add_missing or upgrade_existing. |
root_folder |
— | Root folder path. Required when add_missing. |
tag |
name slug | Override the identity-tag slug (final label = tag_prefix + this). |
tag_prefix |
nalanda- |
Namespace prefix for identity tags. |
stale_tags |
mark |
When a movie leaves: mark (→ nalanda-x-stale) | delete (drop the tag) | keep. |
stale_suffix |
-stale |
Suffix for the mark policy. |
Tagging reconciles to the desired set each run (a bulk PUT /movie/editor) and is
decoupled from search, so "add and tag but don't search" is a valid configuration. A movie
that leaves a collection is marked, deleted, or kept per stale_tags; a re-joining movie has
its stale tag cleared and the live tag restored. Quality-profile and monitored changes to
existing members are opt-in (upgrade_existing / monitor_existing) and diff-first. Nothing
is added unless you ask. Preview the whole pipeline with --dry-run: every intended write
is logged, nothing mutates.
Every collection must declare media: movie, tv, or
mixed, with no default. The setting selects the Jellyfin
library type (movie vs show), the builder dialect, and the arr (Radarr vs Sonarr). A TV
BoxSet holds whole shows, exactly as a movie BoxSet holds whole movies; there are no episode-
or season-level collections.
All three sources are media-dual: most builder keys work for both and dispatch on media (for
example, tmdb_genre hits movie Discover for a movie collection, TV Discover for a tv one).
Only a few keys are entity-specific:
| Movie-only | TV-only | Shared (dispatched by media) |
|
|---|---|---|---|
| TMDB | tmdb_collection, tmdb_movie, tmdb_now_playing, tmdb_upcoming |
tmdb_show, tmdb_network, tmdb_on_the_air, tmdb_airing_today |
tmdb_title, tmdb_genre, tmdb_keyword, tmdb_actor/director/writer/producer/crew, tmdb_company, tmdb_discover, tmdb_list, tmdb_popular, tmdb_top_rated, tmdb_trending_daily/weekly |
| TVDB | tvdb_movie |
tvdb_show |
tvdb_list, tvdb_discover |
| MDBList | — | — | mdblist_list, mdblist_catalog, mdblist_official |
Using a movie-only key on a tv collection (or vice versa) is rejected by the schema; a
mixed collection accepts both families. The TV-specific
keys mirror their movie analogues: tmdb_show ≈ tmdb_movie (show ids), tmdb_network ≈
tmdb_company (TV networks, by id, since TMDB has no network name search), and
tmdb_on_the_air / tmdb_airing_today are the TV charts movies lack.
Matching note. Shows from the shared TMDB builders (the charts,
tmdb_discover,tmdb_genre/tmdb_keyword, people) carry only a TMDB id, whereastmdb_show(by id) and thetvdb_*builders also resolve a TVDB id. Shows are matched TVDB-first, then TMDB, then IMDb, so a chart- or discover-sourced show matches a Jellyfin (or Sonarr) entry only if that entry exposes a TMDB id. If your TV library is indexed by TheTVDB alone, prefer thetvdb_*builders (or id-basedtmdb_show) for those collections, so every show carries a TVDB id to match on.
TVDB ids are Sonarr-native, and TVDB tracks movies too (so tvdb_movie / tvdb_list feed
movie collections, resolved to TMDB/IMDb ids for Radarr/Jellyfin). tvdb_show / tvdb_movie
accept id(s), slug(s), or name(s); tvdb_list takes a list id or slug (curated order);
tvdb_discover is a raw filter escape hatch
(genre / company / country / lang / year / sort, with country + lang defaulting
to usa / eng).
No API key is needed. TVDB v4 keys are per-project, not per-user (per TVDB's docs, "individual
users shouldn't need an API key"), and limits are per-IP, so Nalanda ships its own. The
tvdb_* builders work out of the box, and TVDB is contacted only when one runs. (Maintainers
rotating the bundled key: re-run scripts/encode_tvdb_key.py and push the new strands.)
Metadata provided by TheTVDB. Please consider adding missing information or subscribing. (Nalanda also logs this once per run whenever a
tvdb_*builder is used; see Attribution.)
Add a sonarr: block with enable: true to a tv or mixed collection to manage it in
Sonarr, the TV analogue of radarr:, with the same identity-tag design,
three-state stale policy, and enable off-switch. Shows are matched
tvdb-first, then tmdb/imdb. Set SONARR_URL / SONARR_API_KEY in .env.
| Key | Default | Description |
|---|---|---|
enable |
false |
Opt-in switch. true = manage this collection in Sonarr; false or omitted = inert. |
add_missing |
false |
Add collection shows not yet in Sonarr (tagged on add). |
add_existing |
false |
Apply the identity tag to shows already in Sonarr. |
upgrade_existing |
false |
Reset existing members' quality profile to quality_profile (diff-first). |
monitor_existing |
false |
Reconcile existing members' monitored flag and season monitoring (see below). |
monitored |
true |
Series-level monitored flag for added shows. |
monitor |
all |
Episode/season strategy (see below). |
search |
false |
Search for missing episodes when adding (never existing). |
cutoff_search |
false |
Also search cutoff-unmet episodes on add. |
series_type |
standard |
standard | daily | anime (episode numbering). |
season_folder |
true |
Organise episodes into season folders. |
quality_profile |
— | Profile name or id. Required when add_missing or upgrade_existing. |
language_profile |
— | Sonarr v3 only (v4 dropped language profiles; ignored there). |
root_folder |
— | Root folder path. Required when add_missing. |
tag / tag_prefix / stale_tags / stale_suffix |
as Radarr | Identity-tag controls, identical to Radarr. |
The monitor strategy is the one axis movies lack: which episodes and seasons a show monitors
(not which shows are in the collection). Values: all (every non-special season), future,
missing, existing, pilot, first_season, latest_season, none. It is applied at add
time via Sonarr's addOptions.monitor. With monitor_existing, the deterministic strategies
(all / none / first_season / latest_season) are reconciled each run: the
desired per-season monitored set is diffed against Sonarr and written (via
PUT /series/{id}) only on a difference. The dynamic strategies (future / missing /
existing / pilot) are state-dependent, so Sonarr owns them after add, and
monitor_existing then reconciles only the series-level monitored flag.
A mixed collection builds one BoxSet holding both movies and shows, for example a franchise
with both films and series. Jellyfin BoxSets are media-agnostic (a BoxSet
links whole movies and whole series alike), so this is one collection, not two.
Media-specific keys produce their own media; shared keys produce both:
tmdb_collection/tmdb_movie/tvdb_movieand the movie charts → moviestmdb_show/tmdb_network/tvdb_showand the TV charts → shows- every shared key (
tmdb_genre,tmdb_keyword, the people keys,tmdb_company,tmdb_discover,tmdb_list, thepopular/top_rated/trendingcharts, and allmdblist_*) → both movies and shows
Both key families are allowed in one block; the per-media validation that applies to
movie/tv collections is relaxed for mixed. Members are merged and release-date sorted;
use order: or append: blocks to control ordering as usual.
A Cross-Media Franchise:
media: mixed
tmdb_collection: 1234 # the films (movie endpoint)
tmdb_show: [1234, 5678] # the series (tv endpoint)
overview: A franchise spanning films and series in one BoxSet.
Action Everything:
media: mixed
tmdb_genre: Action # every Action movie and show
limit: 50 # caps EACH media (up to 50 movies and 50 shows)limitapplies per media: a shared key capped atlimityields up tolimitmovies andlimitshows.librariesnames are matched against both pools: a movie matches within a named movie library, a show within a named show library (a name in neither is an error).- A mixed collection may carry both a
radarr:block (for its movies) and asonarr:block (for its shows), each opted in with its ownenable: true. Each syncs only the items of its media and gets its own identity tag, so either a Radarrmovie.tagsor a Sonarrseries.tagswebhook scopes a run to the collection.
The metadata: block lets you write and lock specific fields on individual movies and shows
in Jellyfin, regardless of which collections they belong to. Entries are grouped by media
under metadata.movies and metadata.shows.
metadata:
movies:
- tmdb: 1234
parental_rating: GB-15
sort_title: A Film 2
shows:
- tvdb: 5678
parental_rating: GB-12
title: Custom Show TitleEach entry identifies one item with exactly one provider id, then sets at least one override
field. Movies are normally identified by tmdb or imdb; shows by tvdb, tmdb, or imdb.
| Field | Jellyfin UI label | Notes |
|---|---|---|
parental_rating |
Parental Rating | Written and locked (e.g. GB-15, PG-13). |
title |
Title | Written and locked. |
overview |
Overview | Written and locked. |
sort_title |
Sort Title | Written; see caveat below. |
A bare number like 15 also works as a parental_rating (common for UK and Australian ratings);
it is stored as a string.
Each supported field is written to Jellyfin and individually locked, so a normal metadata refresh cannot overwrite it. A second run with unchanged inputs and Jellyfin state writes nothing (compare-then-write).
Jellyfin stores sort titles via ForcedSortName, which survives normal library refreshes and
Nalanda re-asserts the value each run. However, a "replace all metadata" refresh in Jellyfin
can clear it; the value is restored on the next metadata run.
A separate caveat: Jellyfin's API does not return ForcedSortName, so writing any managed
field to an item resets a sort title that was set manually in Jellyfin but is not declared in
the config. To preserve a manually-set sort title, declare it under sort_title in the entry.
By default, removing a metadata entry or field from config leaves the field locked in Jellyfin
(matching the delete_unconfigured_collections default). Set
settings.unlock_unconfigured_metadata: true to instead unlock the field (drop the field lock;
clear a managed sort title) when an entry or field is removed, so a Jellyfin refresh can
reclaim it.
The metadata job runs on settings.jobs.metadata (a name from settings.run_schedules, an
inline cron, or none/disabled), falling back to settings.run_schedule. Run it on demand:
uv run python -m nalanda run metadata # apply all metadata overrides
uv run python -m nalanda run metadata --dry-run # preview without writingThe metadata state is tracked in .nalanda-metadata-state.json (a sibling of config.yml),
separate from the collection state file.
nalanda serve runs a long-lived daemon (stdlib HTTP, no extra services) so Radarr, Sonarr,
or anything else can push a re-sync instead of waiting on a cron. The webhook routes are
optional: set WEBHOOK_SECRET in .env to enable them, or leave it blank to run on schedules
only -- the daemon still serves /health and fires cron runs, and the POST routes return 503.
The secret gates inbound HTTP only, never the internal scheduler.
There are two POST routes, split by blast radius, plus a health check. When a secret is set,
both POST routes require it in the X-Nalanda-Token header (compared in constant time):
| Route | Auth | Behaviour |
|---|---|---|
POST /trigger |
token | Reactive, always scoped. nalanda- identity tags in a Radarr movie.tags / Sonarr series.tags payload (or a top-level tags) run just those collections. An explicit {"collections": [...]} also works. Empty, unknown, tag-less, or Test payloads return 204. Never runs everything. |
POST /run |
token | Explicit full run, gated by webhook.allow_full_run (default false, otherwise 403). |
GET /health |
none | Liveness, no side effects. |
A debounce window (webhook.debounce_seconds, default 300) coalesces a burst of triggers into
one run; the window resets on each new trigger, so a flood of upgrades becomes a single
re-sync once it goes quiet (Jellyfin needs a moment to finish processing an upgraded file). A
single run-lock keeps runs from overlapping. A manual nalanda run still works while the
daemon is up: the two coordinate through a cross-process lock file next to the state file, so a
contended run waits (logging that it is queued) and then proceeds. Dry-runs skip the lock,
since they write nothing. Scheduled cron runs (see Scheduling) and webhooks
share the same lock and debounce, so they serialize and coalesce too: within a job kind a
pending full run supersedes pending scoped names, and distinct kinds never merge.
settings.webhook holds inbound-request behaviour only: debounce_seconds (300),
max_wait_seconds (an optional cap on the debounce, default none), and allow_full_run
(false). Time-driven scheduling lives in settings.run_schedules / settings.run_schedule /
settings.jobs (see Scheduling). The daemon's deployment knobs (config-file
path, bind address, port) come from the environment or .env: NALANDA_CONFIG
(default ./config.yml), NALANDA_HOST (default 127.0.0.1), and NALANDA_PORT (8842),
with a real environment variable overriding the .env value. The state and cache files
(.nalanda-state.json, .nalanda-cache.db) are written beside config.yml, so pointing
NALANDA_CONFIG at a mounted volume keeps both on it. The default host is loopback so a bare
serve isn't network-exposed; to publish it, set NALANDA_HOST=0.0.0.0 (the container does)
behind a TLS-terminating reverse proxy, since the X-Nalanda-Token secret travels in clear
over HTTP.
Why two files?
.envis per-install launch config: secrets, the service URLs Nalanda connects to, and theNALANDA_*deployment knobs. It is never committed, and Nalanda reads it from the same directory asconfig.yml.config.ymlis the description of what to build and how Nalanda behaves (collections, sync, ordering, the webhook behaviour block). Connection and binding details go in.env; logic goes inconfig.yml.
In Radarr or Sonarr, go to Settings -> Connect -> + -> Webhook and fill in, top to bottom:
- Triggers -- select the appropriate events.
- Tags -- optional; leave blank to send every event, or list specific
nalanda-<slug>identity tags to fire only for those collections. - Webhook URL --
http://<nalanda-host>:8842/trigger(for examplehttp://nalanda:8842/triggerwhen both run on the same Docker network; see Network exposure for the right address in your setup). - Method --
POST.PUTis not accepted. - Username -- leave blank.
- Password -- leave blank. Nalanda doesn't use HTTP basic auth; the shared secret travels in a header instead.
- Headers -- add one: key
X-Nalanda-Token, value yourWEBHOOK_SECRET. Required -- without a matching token the request is rejected (401), and if noWEBHOOK_SECRETis set the routes are off entirely (503).
Use Test to confirm wiring: with a valid token it returns 204 (a success no-op -- the test payload carries no tags), a wrong or missing token returns 401, and no secret set returns 503.
Nalanda caches what the metadata sources (TMDB, TVDB, MDBList) return, so repeated runs don't re-fetch near-identical data; the daemon fires one per webhook and cron tick. It is a best-effort, disposable cache: a missing, stale, or corrupt cache only ever costs speed, never correctness. Jellyfin and Radarr/Sonarr library state is never cached: that is the live state Nalanda reconciles against. The Radarr/Sonarr add-lookup metadata is cached behind the scenes (a source fact, not library state).
settings.cache has an enabled switch (default true) and four duration knobs, each setting
how long a group of source data is kept:
| Knob | Default | Covers |
|---|---|---|
record_cache_duration |
30d |
per-id details (movie / show / collection) + name/id resolution |
list_cache_duration |
1d |
MDBList / TVDB / TMDB list membership |
query_cache_duration |
3d |
discover / genre / company / keyword / person queries |
chart_cache_duration |
1d |
popular / trending / time-windowed charts |
Durations are an integer with a d (days) or h (hours) suffix -- 30d, 6h -- or 0
(or off) to bypass that bucket. Minutes are rejected (an hour is the finest useful TTL).
The cache file (.nalanda-cache.db, SQLite) lives beside config.yml and is safe to delete.
run --refresh-cache forces a single run to re-fetch everything and update the cache; nalanda cache info | prune | clear [namespace] inspects and maintains it. The bare-source report and the one-off build always
read fresh.
The daemon fires runs on cron schedules. A schedule resolves through a three-level cascade, most specific first:
- a collection's own
run_schedule:(level 3); - the per-job-kind default
settings.jobs.<kind>(level 2;collectionsandmetadata); - the global default
settings.run_schedule(level 1).
Each value is a name defined in settings.run_schedules, an inline cron string, or none /
disabled to opt out. Anything that resolves to nothing simply isn't scheduled; it still runs
on demand via a webhook or a manual run. settings.run_schedules is an optional map of
reusable names; any reference may be an inline cron instead. Every value is validated at load,
so a bad cron or a dangling name fails fast. Only nalanda serve acts on schedules; a one-off
nalanda run ignores them.
settings:
run_schedules: # optional reusable names
hourly: "0 * * * *"
daily: "0 4 * * *"
run_schedule: daily # global default (level 1)
jobs:
collections: daily # per-job-kind default for the collection job (level 2)
metadata: daily # per-job-kind default for the metadata job (level 2)
collections:
A Shared Universe:
run_schedule: hourly # per-collection override (level 3)
Some Static List:
run_schedule: none # never scheduled (webhook or manual only)Scope and pruning. Each distinct cron gets its own timer. A per-collection schedule fires a
scoped run of just that collection; the global or job-default cron fires the run that also
prunes, deleting collections and empty artwork folders no longer in config (the maintenance
pass that delete_unconfigured_collections gates). Because pruning rides the default cron,
giving every collection its own schedule with no global or job default means nothing prunes on
a schedule. The daemon warns at startup, and you'd prune via a manual run or POST /run.
Two independent layers decide who can reach the daemon:
- The bind (
NALANDA_HOST, set in the environment or.env) is which interfaces the process listens on. Inside a container,0.0.0.0means all interfaces in the container's network namespace, not your physical host, so on its own it exposes nothing beyond the Docker network. (127.0.0.1inside a container is nearly useless: Docker routes to the container's bridge IP, not loopback, so a loopback-bound container is unreachable even when published, which is why the image sets0.0.0.0.) - The publish (
docker run -p …) is the real exposure decision:- No
-p: reachable only from other containers on the same user-defined network (http://nalanda:8842/trigger). This is all you need when Radarr/Sonarr also run in Docker; the token then only ever crosses the internal bridge, so plain HTTP is fine. -p 8842:8842: binds0.0.0.0:8842on the host, reachable from the LAN. This is where "trusted network, TLS reverse proxy in front" applies, since the token is sent in clear. Needed when Radarr/Sonarr are not containers on the same network.-p 127.0.0.1:8842:8842: publishes to host loopback only (for example, for a reverse proxy on the same host).
- No
Docker publishes ports through its own iptables chain, which sits ahead of
ufw, so a-p-published port can be reachable from the LAN even whenufwappears to block it. Bind-publish to a specific interface (-p 127.0.0.1:8842:8842) rather than relying onufw. Likewise,--network hostdrops the namespace isolation entirely, so the container's0.0.0.0bind becomes the host's.
Every change to Nalanda is reviewed by a human, but the code itself is written with AI. If you prefer fully hand-written software, this may not be the project for you.
AI-generated issues and pull requests are welcome, but you must review them thoroughly yourself before submitting, to the same bar the code is held to. Those that appear not to have been will be closed.
Nalanda is built on metadata from these providers:
- Metadata provided by TheTVDB. Please consider adding missing information or subscribing.
- This product uses the TMDB API but is not endorsed or certified by TMDB.
- List and cross-source rating data via MDBList.
Nalanda is licensed under the GNU Affero General Public License v3.0 or later
(AGPL-3.0-or-later). See LICENSE for the full text.