Settings reference

Every Shortlist configuration key, container environment variable, per-row override and default value, plus what changes on Plex the moment you save one.

Environment variables (container)

Variable Default Live or seed
PORT 5959 live
TZ Etc/UTC live
PUID / PGID 1000 live
SHORTLIST_CONFIG /config live
PLEX_URL, PLEX_TOKEN, TAUTULLI_URL, TAUTULLI_APIKEY, TMDB_APIKEY seed once: copied into settings on first boot, ignored afterwards
LOG_LEVEL DEBUG seed once: initial value for the log.level setting; change it live in Settings → Advanced
SHORTLIST_DRY_RUN unset live: when set (1/true), EVERY run is forced to dry-run. The app builds its clients and logs the would-be changes but writes NOTHING to Plex/plex.tv. Safe mode for a demo/test instance pointed at a real server (even a manual “Run now” can’t modify it)
SHORTLIST_ENABLE_DOCS unset live: when set (1), exposes the API docs at /api/docs and /api/openapi.json (off by default)
APP_BASE_PATH / live: serve the app from a subpath behind a reverse proxy, e.g. /shortlist. Read at startup, so the published image works unmodified — no rebuild. Accepts /shortlist or /shortlist/. The proxy just forwards; it does not need to strip the prefix. See Serving from a subpath.

Serving from a subpath

Set APP_BASE_PATH and point the proxy at the container. No rebuild, and no prefix-stripping middleware — the app recognises its own prefix, rewrites the asset URLs in the shell it serves, and publishes the prefix to the SPA so the router and API client use it too.

services:
  shortlist:
    image: ghcr.io/stevezau/shortlist:latest
    environment:
      - APP_BASE_PATH=/shortlist

Traefik — router and service only:

http:
  routers:
    shortlist:
      rule: "Host(`media.example.com`) && PathPrefix(`/shortlist`)"
      service: shortlist
  services:
    shortlist:
      loadBalancer:
        servers:
          - url: "http://shortlist:5959/"

nginx — two blocks. location /shortlist on its own is a prefix match, so it would also swallow a sibling app at /shortlistings; ^~ /shortlist/ matches only the real subtree, and the exact-match block redirects the bare URL people actually type. Note there is no trailing slash on proxy_pass — that is what forwards the prefix intact:

location = /shortlist {
    return 308 /shortlist/;
}

location ^~ /shortlist/ {
    proxy_pass http://shortlist:5959;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

A proxy that strips the prefix anyway still works — an already-stripped path simply doesn’t match and is passed through untouched. That is also why the container’s own healthcheck, which requests an unprefixed /api/system/health on localhost, keeps working.

Leaving it unset serves the shell’s bytes exactly as they shipped.

Test through the proxy, at https://host/shortlist/. A blank page at the container’s own port — or at any URL without the prefix — is expected, not a fault: the app still answers there (that is how the healthcheck works), but the SPA it serves is built for the prefix and renders nothing outside it.

If it is blank through the proxy, check the container log first: it states the base path it is using at startup, and warns if APP_BASE_PATH held something it could not use (a query, a fragment, a space, an escaped or relative path) — in which case it ignores it and serves from the root, which on its own looks exactly like a proxy problem.

Settings keys (DB-backed; Settings UI or PUT /api/settings)

Key Default Notes
plex.url / plex.token token stored Fernet-encrypted, redacted in API. Saving a URL/token that answers with a DIFFERENT machine id is refused (409): every record Shortlist holds — the delivery ledger, share-filter snapshots, the user list — belongs to the linked server, so switching is a re-link (uninstall, then set up again), not a settings edit
tautulli.url / tautulli.apikey optional
tmdb.apikey required for personal mode. Stored Fernet-encrypted and redacted in the API like every other key; an install that predates that has its plaintext value encrypted on the next boot.
curator.provider none anthropic | openai | openai_compatible (any local/self-hosted OpenAI-API server) | google | none. ollama is the pre-merge name, still accepted
curator.api_key / curator.model BYO key; sensible default model per provider. Optional for openai_compatible — a server on your own network needs none, a hosted gateway on the same API (ollama.com, OpenRouter) does; both the wizard and Settings offer the field
curator.openai_base_url your local/self-hosted server’s URL (Ollama, llama.cpp, LM Studio, vLLM, LocalAI, OpenRouter). A bare host gains /v1 automatically. curator.ollama_url is the pre-merge key, still read as a fallback
row.name_template ✨ {library_name} Picked for You {library_name} (the delivering library), {top_seed} and {user} placeholders. This IS the default row’s title, so editing it here renames every user’s collection on Plex immediately. The same reconcile the Rows page runs. Refused with a 422 if another row is already titled from the same template — two rows that render one title become one collection on Plex, since per-person rows share a label and are told apart by title alone. The same check guards creating or renaming any row.
row.size 15 any whole number 5–40 (free number picker in the UI); size PER library — each library a row targets fills to this
rows.hub_anchor {} Per-library placement in Plex’s Recommended shelf: {"<sectionKey>": {"top": true}} (very top, no anchor) or {"<sectionKey>": {"anchor": "<collection title>", "before": false}} (next to a collection). A per-ROW override may also use {"row": "<row slug>", "before": false} to sit AFTER another Shortlist row; the global default here cannot, since every row following it would include the anchor row itself. before: true with a row anchor only holds when Shortlist does NOT also position that row — it keeps each block it places contiguous at a point of its own, so a row inserted ahead of one would be moved back every run. When both are positioned, the request is refused with an audit record and the row takes this library’s default instead; after, and anchoring to a collection, always work. If the anchor is a collection, it must itself be on one of that library’s Plex shelves (Plex → the library → Manage Recommendations): one that is on none has no position to sit next to, so the rows are left where they are and the run records that it could not place them. Plex’s own built-in rows (“Recently Added” and friends) are always valid anchors. Empty does NOT mean “leave Plex’s order alone”: with no anchor set anywhere, every Shortlist row is moved to the TOP of each library’s shelf, which is the shipped default — without it, aborted runs and new promotions scatter rows to wherever Plex appends them. To genuinely leave the order to Plex or to a co-managing tool, set rows.manage_shelf_order: false. Re-applied at the end of every run, by the nightly privacy sync (05:15 by default), by Check and fix rows on Plex, and whenever a change to who-sees-what triggers a privacy sync — so a row promoted between runs is repositioned on the next of those rather than waiting for the next nightly build. Only rows actually out of place are moved, and the shelf is re-read afterwards to confirm it took — if another tool (Kometa, agregarr) is reordering the same shelf, that is recorded as unverified rather than reported as success; only Shortlist’s own hubs move, the anchor is read-only
rows.manage_shelf_order true master switch for Shortlist touching the Recommended-shelf ORDER at all. true (default) applies your rows.hub_anchor placement at the end of each run; false = never reorder the shelf, leaving the order entirely to a co-managing tool like agregarr/Kometa
recommendations.watched_pct 0.0 max share of a row that may be already-finished titles (0 = all fresh, 1 = no filtering); per-row overridable. The “already-finished” set is each user’s COMPLETE watched set, read from Plex AS them each run. So it includes titles they only marked watched, not just played (see Watched titles).
recommendations.refresh_days 8 REFRESH CADENCE IN DAYS, not a nightly shuffle: 0 = frozen once built, 1 = rebuild every night, N = every N days. On a rebuild night the strongest ~⅔ stay and the weakest third is swapped for new picks; other nights the row is reused unchanged (no rebuild, no Plex write). Per-row overridable. Except on a row whose name uses {top_seed} or that cycles its seed, which always rebuilds nightly (a row naming a recent watch cannot be allowed to lag behind it) and where the editor hides the control accordingly. Was recommendations.freshness, a 0–1 fraction a curve stretched onto 1–14 days; migration 0065 converts every stored value to the day count it already meant, so no row changes pace. Anything up to 365 is now sayable — the fraction could not express a cadence slower than a fortnight.
recommendations.idle_hold_days 0 HOW LONG A ROW WAITS WHEN ITS OWNER HAS WATCHED NOTHING since it was last built, in days. 0 (the default) = off, so a row rebuilds on its recommendations.refresh_days cadence whatever they have been doing. Set to N and a row due to rebuild is left alone — no re-pick, no Plex write — until they watch something or the row turns N days old, whichever comes first. The ceiling is what makes this a hold rather than a freeze: the row nobody watches is the one that most needs to look different next time they open Plex. Must be set HIGHER than the row’s EFFECTIVE recommendations.refresh_days to do anything: a row is rebuilt on its due night, so by its next due night it is exactly one cadence old and the ceiling releases at that age — a 30-day hold on a 30-day row never holds, and a hold on a frozen row (cadence 0) never fires at all. Effective, not stored: a row named after a watch ({top_seed}) is forced to rebuild nightly, so any hold above 1 day works there. The Settings control, the row editor and /api/support/row-schedule all warn when a hold cannot fire — at or below the cadence, or on a frozen row. Overridden in progress by a settings change (including a blocked seed) and by a moved seed on a {top_seed} row, so neither waits for the ceiling. Per-row overridable. Never applied to a row that cycles its seed (seed_window > 1), whose rotation is driven by the cadence rather than by new watches, nor to a shared row, which has no single owner. Saves Plex writes, not AI tokens — the candidate gather runs above the rebuild decision on every path, at every cadence. Reported per row+library in the run trace as held_idle.
recommendations.recency 0.5 How much a title’s RELEASE DATE counts when ranking it: 0 = ignore age entirely, which is how ranking worked before this setting existed; 1 = every ~8 years of age halves a title’s weight (0.5 = every ~16). A WEIGHT, never a filter — an old title is only ever asked to be a better match, never excluded, so nothing is dropped for being old. Distinct from recommendations.refresh_days above, which is how OFTEN a row rebuilds rather than which titles win. Per-row overridable. Applies to EVERY install, existing servers included: upgrading to the release that introduced it shifts each row towards newer titles on its next rebuild night. Set it to 0 for age-blind ranking (how it worked before).
recommendations.recent_count 10 how many of a person’s most recent watches the llm_web source searches per row (one cached search each, “what to watch if you liked X”); results cached 7 days and shared across users so a popular title is searched once server-wide; overridable per row, and per person on a row (User → Rows), each falling back to the next: user → row → this global (1–25)
recommendations.max_seeds 30 how many of a person’s watched titles SEED a row. The titles every discovery source searches from, not just llm_web. Fewer = a tighter row about a couple of things; more = broader coverage of their taste. Overridable per row (1–100), which is where a deliberately narrow value belongs: a row named {top_seed} wants 1 so its title is true. This server-wide default is floored at 5 because seeds are shared across the media types a row covers, so a global 1 or 2 would leave every movies-and-TV row with one half unseeded (5–100)
recommendations.min_history 10 how many titles someone must have watched before Shortlist recommends from THEIR taste. Below it they are a cold start and get whatever recommendations.cold_start says. Floored at 1: at 0 nobody is ever cold, which would silently disable the whole path (1–100)
recommendations.cold_start popular what a cold-start person gets. popular = a row of the server’s highest-rated titles (the long-standing behaviour). skip = no row is built for them, and any row they already have is REMOVED, so skipping means gone rather than left to go stale. Overridable per row — a {top_seed} row is the one worth skipping, having no seed to name itself after. Note a {top_seed} row is not built for a cold-start person either way unless it has a fallback_name: popular decides the row’s CONTENTS, and a row still needs a title
recommendations.rating_source tmdb which service’s score a row ordered by Highest rated sorts on. tmdb is already carried on every candidate and costs no lookups; imdb/trakt/tomatoes/metacritic come from MDBList (one cached lookup per title, shared by every row and user) and need requests.mdblist.apikey. Without a key, or once the daily quota is spent, the row falls back to TMDB for its whole ordering rather than mixing two scales
privacy.hide_shared_from_disabled true when on, disabling a user hides EVERY shared row from them too — even public “Popular on this server” rows. So a disabled user sees nothing from Shortlist. Off = disabled users still see public shared rows like any account with library access. Changing this setting, or re-enabling someone, rewrites the share filters straight away rather than waiting for a run.
candidates.sources ["tmdb_similar","tmdb_discover"] sources to pool: tmdb_similar, tmdb_discover, trakt, llm_web. Each enabled source gets a fair share of the pooled candidates — a wide source can’t crowd out a narrow one
llm_web.search_provider native which backend the llm_web source searches with — exactly one: native (the provider’s own web-search tool, Claude/GPT/Gemini only), exa (the hosted Exa search API), or searxng (your own self-hosted SearXNG — no account, key or per-search bill; it still forwards each query on to real search engines). Either external works for every provider, including a local model that cannot search on its own. Naming a backend never falls back to another. A fourth value, auto (native unioned with an external), was removed in 1.3 — migration 0063 pins every existing install to the backend it was actually using
trakt.client_id Trakt API key; required for the trakt source; encrypted. Trakt now requires a paid VIP subscription to create an API key, so this source is unavailable on a free Trakt account — everything else in Shortlist works without it
exa.apikey Exa web-search API key; powers the llm_web source for any provider, including a local model that cannot search on its own (searxng.url is the self-hosted alternative — you need only one); encrypted
exa.search_type deep-lite how hard Exa works on each search. Four modes are offered: instant ($0.007), auto ($0.007), deep-lite ($0.012, the default) and deep ($0.012). Measured live on one query with the structured output Shortlist always requests, deep-lite extracted 42 titles, deep 32 and instant 11; auto is Exa’s own “recommended” setting and measured the weakest of the four. fast and deep-reasoning are not offered — fast extracted zero, and deep-reasoning matched deep for twice the wait and more money. instant also puts a release year on barely one title in nine, and the year is what Shortlist needs to look a title up. Every search is cached for 7 days and shared across every user on the server, so the extra half-cent buys candidates for the whole roster, not one person. All six values remain valid on Exa’s API; a stored setting naming a dropped one falls back to the default.

What web search costs in practice. Measured on a 46-user server (run 18, 3 Sep 2026): 662 billable Exa searches in one nightly run, 380 more served from the shared 7-day cache — a 36% hit rate — for $7.94 a night at deep-lite. Cost scales with distinct recent watches across the roster, not with user count, because a title many people watched is searched once for everyone. The per-run figure appears on the Web search card in Settings (“Last run: N web searches”) so it never has to be estimated.

Key Default Notes
searxng.url address of your own SearXNG instance, e.g. http://your-host:8080. Powers the llm_web source for any provider. SearXNG is a metasearch proxy, not an index: it forwards each query to real engines (Google, Brave, DuckDuckGo, …) and merges what they return — so queries do leave your network, just with no account, key or bill attached. Those engines rate-limit and CAPTCHA self-hosted instances, so expect some to fail on any given search; the Test button names the ones that did. Its JSON API must be enabled — add json to search.formats in SearXNG’s settings.yml and restart, or it answers Shortlist with a 403. A reverse-proxy subpath (https://example.com/searxng) is kept as given
searxng.username username, only if you keep SearXNG behind a reverse-proxy login (SearXNG itself has no auth)
searxng.password password for that login; encrypted. Put the login HERE, not in searxng.url — a URL carrying user:pass@ is rejected, because that value is stored in the clear, returned by the API and recorded verbatim in the immutable settings.change audit event
plex.timeout_s 45 seconds to wait on a single Plex Media Server call before giving up and retrying. Reads are near-instant, but rebuilding a big library’s collection (a TV row on a large server) legitimately takes 15-20s+, so too low a value times those out and forces a wasteful retry. Range 5-300. Advanced
plextv.throttle_s 0.0 FLOOR (min seconds) between plex.tv writes. 0 = fire as fast as plex.tv accepts; the client backs off adaptively on a 429 (jumps to ≥1s, doubles, capped 30s, eases back on clean writes), so 0 is safe. Range 0–60
log.level DEBUG container log verbosity: ERROR|WARNING|INFO|DEBUG|TRACE. DEBUG (default) narrates a run in full. Per-source candidate counts, AI calls with timing/tokens, cache hits, throttle waits; TRACE adds full AI prompts; INFO trims to stage narration. Applied live. TRACE reaches the container log only — the in-app Logs view reads a file sink opened at DEBUG, so it has no TRACE filter
run.concurrency 4 how many users a run processes at once (1–16). Only history/candidate/AI reads overlap; every Plex + plex.tv write stays serial. 1 = fully sequential
runs.retention 3 how many months of run history to keep; after each run, anything older is auto-pruned (runs + per-user traces + activity logs deleted; picks and deliveries are always kept. The first is the dashboard’s history, the second is what tells a cleanup which Plex collection is which). 0 = keep everything forever
events.retention 0 (forever) how many months of the audit trail (events) to keep (0–24). Kept forever by default: “what changed on whose share at 03:31” is the record you want long after the run detail around it is gone. 0 = never prune. Set from Settings → Advanced → “Change log kept”
jobs.max_parallel_readonly 3 how many READ-ONLY background jobs may run at once (1–8). Jobs that write to Plex/plex.tv are always exclusive and never overlap a run. Share-filter writes are read-modify-write merges, so two at once would lose one of them. Read-only: sync.history, backup.take, maintenance.prune and watch.reconcile. sync.users counts as a writer because it renames collections. Dial to 1 if your PMS objects to the concurrency
sync.watch_cron "" (daily 04:17) cron expression for the watch-history sync schedule. Blank = built-in default. Set from the job’s frequency picker on the Jobs page
sync.watch_full_days 7 how often the RECONCILE pass runs, in days (1–90). Every watch-history read — the nightly job and a run’s own top-up alike — reads each library in full, so this is not what decides whether a complete read happens — it gates the one thing that acts on a title being ABSENT and cannot correct itself later: sweeping a library removed from the server, which believes a single /library/sections answer for every user. Dropping cached titles the read no longer returned, and withdrawing pick credit, are NOT gated here — a correction you have already made in Plex should not take up to a week to show up. Reading in full rather than “what changed since last time” is what catches a series marked watched by hand: a show’s own date can lag the episodes it counts, so a change-ordered read (lastViewedAt) sorts it behind the cursor and never sees it (issue #108). Reading everything costs nothing measurable: 27.4s against 27.3s on a live 47-user, 3-library server, because Plex sends a 500-row page per library either way. A reconcile that would drop more than half a library asks the server a second time first, and keeps the titles if the two answers disagree
sync.users_cron "" (daily 04:47) cron expression for the user-list sync schedule. Blank = built-in default
privacy.sync_cron "" (daily 05:15) cron for the nightly privacy sync. A re-merge of every account’s share filter. It builds, delivers and promotes nothing, so it can only ever make the server more private; it is the cheapest safety net against drift now that nothing verifies hiding after the fact
rows.visibility_cron "" (daily 00:00) cron for applying each row’s day schedule (collections.show_days). Midnight, not 03:30 with the runs: a Monday row would otherwise stay on people’s Home until 03:30 on Tuesday, and a row that rebuilds weekly for days. Costs nothing at all — one query, no clients built — on a server where no row narrows its days; where one does, the pass is a share-filter merge plus the ~5ms visibility flips, run nightly and idempotently rather than tracked in state
sync.check_cron "" (daily 05:45) cron for the drift check — after the rows build (03:30) and the privacy pass (05:15), so it checks the state those left behind. The ONE schedule that can be switched off entirely: it writes corrections to Plex, so choosing Off in its frequency picker (Jobs → Check and fix rows on Plex) stores an empty value the scheduler reads as an explicit “off” rather than “inherit the default” (every other blank cron means “use the built-in default”). Because of that, its picker is driven by the EFFECTIVE cron from GET /api/schedule, not by the raw setting. The two are indistinguishable in GET /api/settings, which folds the blank default in. The way back is the Built-in (05:45) chip in the same picker, which saves null — that deletes the stored value, so the job inherits the built-in cron again rather than pinning a copy of it
maintenance.prune_cron "" (daily 06:15) cron for the retention prune. It applies runs.retention and events.retention and drops expired cache rows. Last of the night, after every other schedule has finished writing, so it trims a settled database. The prune is also queued after every run; this schedule is the FLOOR under that, for a server whose rows have no cron (or one paused from the Danger Zone) and so has no runs to queue it. Local database housekeeping. Nothing on Plex changes. Blank = built-in default
recommendations.blocked_shared_seeds [] TMDB ids that must never seed a SHARED row. Separate from each person’s own blocked seeds on purpose: a shared row is public, so letting one person’s block reshape what everyone sees would make an individual preference into a server-wide edit nobody else can see or undo
recommendations.use_plex_ratings true when on, a title someone rated low in Plex stops being used to find similar things FOR THEM. Their rating arrives on the watched read Shortlist already makes, scoped to their own share token, so it costs no extra calls and one person’s opinion can never reach another’s row. A title nobody rated is unaffected — on a real 50-account server that was 99.7% of watches. Never applied to a SHARED row, for the same reason as blocked_shared_seeds above. Ratings that look tool-written (Kometa and friends sync IMDb scores into the same field) are ignored: Plex’s own controls write whole numbers only, so a fractional value was not typed by a person, and an account whose ratings are mostly fractional is disbelieved wholesale
recommendations.dislike_threshold 2.0 the 0–10 Plex rating at or below which that happens, inclusive. 2 = one star, which is also where a thumbs-down lands. Capped at 6 (three stars): above that “disliked” stops being a fair reading, and 10 would suppress every rated title at once (0–6)
paused_all false Danger-Zone “stop all runs” switch; pauses without disabling anyone
requests.enabled false ask Radarr/Sonarr for picks the library lacks
requests.target arr where a request is filed: arr posts to Radarr/Sonarr directly; overseerr files a request in Overseerr/Jellyseerr/Seerr and lets it drive them (its blocklist is honoured where the build serves one). Exclusive — on the overseerr route the Radarr/Sonarr targets are ignored entirely, along with every per-row quality-profile, root-folder and monitor override, because the *seerr owns those choices
requests.overseerr.url / .apikey Overseerr, Jellyseerr or Seerr (all share the /api/v1 API); key stored Fernet-encrypted, redacted
requests.overseerr.request_as_user_id 0 which Overseerr account requests are filed as. The picker lists every account, local ones first and people after, with whether each auto-approves. Choosing a person puts their name on every request and spends their quota — the screen says so when one is picked — but on many servers a person is the only account that does not auto-approve. 0 = omit userId and let the API key’s own account own the request — that account is normally an admin, so its requests auto-approve and go straight to Radarr/Sonarr. Point it at an account without auto-approve to get a second approval gate inside Overseerr. Shortlist never creates the account
requests.radarr.url / .apikey Radarr (movies); key stored Fernet-encrypted, redacted
requests.radarr.quality_profile_id / .root_folder 0 / — picked from dropdowns in the UI (fetched from Radarr)
requests.sonarr.url / .apikey Sonarr (shows); key stored Fernet-encrypted, redacted
requests.sonarr.quality_profile_id / .root_folder 0 / — picked from dropdowns in the UI (fetched from Sonarr)
requests.sonarr.monitor all how much of a show Sonarr monitors — and so downloads — when Shortlist adds it. Sonarr’s own Add Series “Monitor” choice, passed through: all | firstSeason | lastSeason | pilot | none. all takes the whole back catalogue of a long-running show the night it is added; firstSeason is a taster; none files it unmonitored and downloads nothing. Every mode except all is sent with monitorNewItems: none, so a restricted show does not pick up new seasons as they air. The rest of Sonarr’s list is deliberately not offered — future, existing and recent each monitor NOTHING on a show the server doesn’t have yet, so on a new add they are an obscure spelling of none. Shows Sonarr already tracks are skipped whole, so this only ever applies to a NEW add
requests.rating_source tmdb tmdb (no setup) | imdb | trakt | tomatoes (Rotten Tomatoes) | metacritic — all non-TMDB sources come from MDBList, normalised to a 0–10 scale
requests.mdblist.apikey free MDBList key; required for any non-TMDB rating source; encrypted. One lookup returns every source and is cached 7 days; on a 429 (daily cap) the gate falls back to TMDB and the owner is notified
requests.min_rating 7.0 score floor (0–10) on the chosen source
requests.language_mode any how a title’s ORIGINAL language is treated: any (one bar for everything — what Shortlist has always done) | prefer (other languages must clear requests.min_rating_other to auto-send; below it they wait in the inbox) | only (never request another language at all — these are dropped, not queued)
requests.preferred_languages ["en"] ISO 639-1 codes counted as preferred (TMDB’s original_language). Never read while the mode is any. An EMPTY list is meaningful: in only mode it requests nothing. A title whose language is unknown — only a non-TMDB source such as Trakt produces one — counts as preferred
requests.min_rating_other unset auto-send floor for a title NOT in a preferred language, when the mode is prefer. Unset (null) means follow requests.min_rating + 1.5, so it tracks your own floor rather than a number Shortlist picked — a 6.0 server starts at 7.5, an 8.0 server at 9.5. Set a number to pin it; 0 is a real bar (nothing fails it), not “unset”
requests.min_votes 100 vote-count floor on the chosen source
requests.min_demand 1 request only titles wanted by ≥ N distinct people within one row — counted per row, so a title one person wants in three rows is 1 in each, not 3
requests.min_year 0 0 = no lower bound; else request only titles from ≥ this year (a show is judged by its first-air year)
requests.max_year 0 0 = no upper bound; else request only titles from ≤ this year. With min_year, forms a release-year window; a candidate with no known year is excluded whenever either bound is set
requests.max_per_run 5 hard cap on titles auto-requested per run, both apps. When requests.rating_source is not tmdb, this also sets how many MDBList rating lookups a run may spend (4x, floor 20) — so raising it lets a run rate more titles before it gives up, not just send more. The budget counts lookups that cost an API call; a rating already in the cache is read for free and does not use any of it.
requests.auto_send true false = fully manual; every qualifying title is queued
requests.auto_min_demand 3 auto-send only titles wanted by ≥ N distinct people within one row (see requests.min_demand)
requests.auto_min_rating 8.0 …and rated ≥ this on the chosen source; rest are queued
requests.tag shortlist global tag on every requested title (created in the app; "" = no tag). Arr route only — Overseerr’s POST /request body carries no tags field, so neither this nor requests.auto_user_tag travels that route; the request_as_user_id account is the attribution instead
requests.auto_user_tag false also tag each requested title with the WANTING PERSON’S slug, so the Arr shows who it was added for. Off by default; a per-user request_tag replaces the slug rather than stacking with it, and a row may override this either way (req_auto_user_tag). The tag records who TRIGGERED the add: a title the Arr already tracks is skipped whole, tags included.

Per-row request overrides

Any per-person row may override these in the row editor; the column is the collections column name, and NULL always means “inherit the global requests.* setting”.

Row column Overrides
req_min_rating requests.min_rating
req_min_votes requests.min_votes
req_min_demand requests.min_demand — counted WITHIN the row
req_min_year / req_max_year requests.min_year / requests.max_year
req_auto_send requests.auto_send
req_auto_min_demand requests.auto_min_demand
req_auto_min_rating requests.auto_min_rating
req_max_per_row this row’s share of requests.max_per_run; may only restrict it. Blank = inherit the global; 0 = this row never auto-sends, and its picks queue for approval instead
req_radarr_root_folder requests.radarr.root_folder
req_radarr_quality_profile_id requests.radarr.quality_profile_id
req_sonarr_root_folder requests.sonarr.root_folder
req_sonarr_quality_profile_id requests.sonarr.quality_profile_id
req_sonarr_monitor requests.sonarr.monitor — a taster row can take season 1 only while every other row keeps the whole show
req_language_mode requests.language_mode — a kids row can be English-only while an anime row stays on any
req_preferred_languages requests.preferred_languages — JSON, so [] (cleared) stays distinct from NULL (inherit)
req_min_rating_other requests.min_rating_other. NULL inherits the global, which may itself be “follow the floor” — in which case this row derives from ITS OWN req_min_rating
req_auto_user_tag requests.auto_user_tag

requests.enabled, requests.rating_source, requests.mdblist.apikey, requests.max_per_run and the Arr URLs and API keys are server-wide and cannot be overridden per row — the first four are the run’s ceilings and its one rating account, and the last two mean a row files into a different folder on the SAME Radarr, not a second one.

Shared rows carry none of these: built from already-watched titles, they surface nothing missing.

How settings take effect

What particular keys above actually mean, what a save is checked against, and what changes on Plex without waiting for a run.

The AI provider (curator.provider) does not rank candidates or write reasons — the engine does the diversification and writes the genre-template reasons itself. The provider has exactly one job, the llm_web source: it turns a person’s recent watches into web searches for what to watch next. So a run needs a provider only when llm_web is enabled; every other source is provider-free, and with curator.provider = none you still get full rows ranked by score with plain reasons.

PUT /api/settings validates values, not just keys. plextv.throttle_s must be 0–60 (0 = fire as fast as plex.tv accepts, with adaptive 429 backoff), row.size must be 5–40, paused_all must be a real boolean, and candidates.sources / curator.provider are checked against their known values.

null on a schedule key means “use the built-in default”. For any of the six *_cron keys, PUT /api/settings {"values": {"sync.check_cron": null}} DELETES the stored value rather than writing one, putting the job back on the built-in cron GET /api/schedule reports as default_cron, and the live APScheduler trigger is rebuilt in the same request, not at the next restart. It is the only way back for sync.check_cron, where a stored "" means OFF rather than “inherit”; writing the default expression itself would pin a copy of today’s value instead. For the other five, null and "" land in the same place, because a blank already means “inherit”.

Candidate sources are set globally (candidates.sources) and can be overridden per row (collections.candidate_sources, [] = inherit the global set; valid values: tmdb_similar, tmdb_discover, trakt, llm_web). llm_web proposes titles to watch next from a live web search, each resolved via TMDB search then library-verified. It works on every AI provider via llm_web.search_provider, which names exactly ONE backend: native (the default) uses the provider’s own web-search tool (Claude, GPT, or Gemini), exa uses the hosted Exa API (exa.apikey), and searxng uses your own SearXNG instance (searxng.url). Either external backend is a path for a local model, which cannot search on its own. Only the named backend runs, so a title is never searched — or billed — twice. A fourth value, auto (native unioned with an external), was removed in 1.3; migration 0063 pins every install to the backend it was actually using. When a source’s dependency is missing, the Settings UI keeps the toggle usable but shows an inline fix (enter the key right there, or set up an AI provider). It never reads as on while silently doing nothing.

Config changes reconcile onto Plex immediately, without waiting for a run. Deleting a row, disabling a user, and dropping a user from a row’s audience all remove the now-stale collections (a removal, so gate-exempt); renaming a row retitles its collections in place for every user (privacy-neutral, since the hiding filter is keyed on the row’s label, which never changes). A per-person row’s per-user collection is found by the exact title the last run delivered for it (the run’s persisted breakdown), scoped to that user’s own label, so a reconcile can never touch another user’s row or a foreign (Kometa) collection. Each row also has a Remove from Plex action, under Remove or delete on the row (POST /api/collections/{id}/cleanup, dry-run-able), for an on-demand sweep. Every reconcile is audited.

A row builds a Plex collection in each library it targets (collections.library_keys, a list of Plex section keys; [] = every library of the row’s media type. The default). A row’s media is derived from the types of its selected libraries. This lets an owner point a row at a specific library (e.g. only “4K Movies”) on a server with several libraries of one type. A row builds per library: each targeted library seeds from its own watched history and fills to row.size on its own, so a movies-and-TV watcher gets a full movie row AND a full TV row.

Placement is per row and held once per audience: collections.placement for the owner’s own collection and collections.placement_friends for each friend’s (both both | home | library | off, default both). Each decodes to two of Plex’s three promotion flags. home is promotedToOwnHome on the owner’s side and promotedToSharedHome on the friends’ side, library is promotedToRecommended, and off claims neither surface (the collection still exists and is still browse-hidden, so it stays reachable from the library’s Collections tab). Exception: when a run cannot map an existing collection back to its row, that collection keeps its audience’s Home flag for that run, never the Recommended shelf, which is the one surface the owner cannot filter.

When a row appears (collections.show_days)

Placement decides where a row shows; show_days decides which days. It is a list of ISO weekdays (1 = Monday .. 7 = Sunday); [] means every day, which is what every row carries after migration 0088, so upgrading changes nothing. There is deliberately no way to spell “never” — switching the row off already means that, and the editor will not let you deselect the last day.

On a day it is off, the row resolves to placement off for that day: the collection, its label, its titles and its custom order all stay exactly as they are, and only Plex’s three promotion flags are cleared. So a day off costs one ~5ms visibility write per collection and coming back costs another — neither pays the membership-write cost (up to ~26s per operation on a large TV library) that rebuilding a row would.

Applied by the rows.visibility job, which runs at midnight (rows.visibility_cron, default 0 0 * * *) and again immediately whenever you change a row’s days. Midnight rather than with the nightly run at 03:30: a Monday row would otherwise stay up until 03:30 on Tuesday, and a row that rebuilds weekly for days. The job holds no state — it recomputes today’s answer from the schedule every time, so anything one pass cannot do the next one does. On a server where no row narrows its days (all seven selected is stored as none, so that is every server until somebody picks a subset) it does nothing at all: one query, no clients built, no Plex or plex.tv calls. Where a row IS scheduled it runs nightly, costing a share-filter merge plus one ~5ms visibility write per collection. It deliberately does not reorder the Recommended shelf — the nightly run owns position — so a row shown at midnight sits in its library’s default slot until the run places it.

Showing a row re-merges every account’s share filters first and refuses to promote anything if that fails (plex-safety rule 1): somebody may have joined the server while the row was hidden, and their share carries no label!= exclude for it yet. A paused person’s rows are never brought back by a schedule.

Days are the server’s local days — the same clock a row’s rebuild cron runs on. A viewer in another timezone sees a row turn over at the server’s midnight, not their own. Some Plex clients cache the Home screen; a Roku re-reads it on its own, a Shield needs you to leave Home and come back.

The two sides are independent because every person gets their own Plex collection, so promotedToRecommended is set per collection rather than once for the row. That is what lets an owner keep their own row on the Recommended shelf without every friend’s row landing there too. The one thing it cannot do is the reverse: a friend’s row on the Recommended shelf is also visible to the owner, because the owner has no share filter to hang a label!= exclude on. Shortlist says so at the control rather than pretending otherwise. A shared row is one public collection rather than one per person, so it has nothing to split on, so it takes both Home flags and the union of the two library settings.

WHERE in that shelf it sits is the Position control (collections.hub_anchor, per library: {"top": true}, {"row": "<row slug>", "before": bool}, or {"anchor": "<collection>", "before": bool}); it replaces the old pin_top toggle (still honoured for rows not yet re-saved). This order is Plex’s Managed Recommendations, which are server-wide, because Plex exposes no per-viewing-user hub order.

A row can be positioned relative to another Shortlist row (row, a row slug) or to a foreign collection (anchor, a title) — one or the other, never both; row is what the engine reads first. It is a slug and not a title because a per-person row is one Plex collection PER PERSON, so a title names one account’s copy and would place the row for that account alone. The rows of a library are then applied in dependency order, so a row always lands after the one it follows has itself been placed. Two rows pointing at each other, or a row pointing at itself, is refused when you save it; if an anchor row has nothing in that library yet, the rows following it are left where they are for that run rather than falling back to a different slot.

Request tags are three-layered: the global requests.tag setting, a per-user request_tag (PATCH /api/users/{id}), and a per-row request_tag (collections, per-person rows only: shared rows never request). A requested title is tagged with the union of the global tag, every wanting user’s tag, and the tag of every per-person row that user is in the audience of; the queued tags round-trip through GET /api/requests (tags[]) and are applied on send.

The per-user layer can also be filled in automatically. With requests.auto_user_tag on, a user who has no request_tag of their own contributes their SLUG instead, so every request is attributable to a person in Sonarr/Radarr without hand-setting a tag on each user. An explicit request_tag REPLACES the slug rather than stacking with it — carrying both is the clutter the automatic tag was dropped for in 2026-07. A row may override the switch either way (req_auto_user_tag; NULL inherits), and the override governs only the automatic slug: a tag the owner typed on a person is never dropped.

Slugs are sanitized to the Arr tag charset (a-z, 0-9, -) before being sent, so moo_house becomes moo-house. Note what the tag can and cannot tell you: a title the Arr ALREADY tracks is skipped entirely by add_movie/add_series, tags included, so the tag records who triggered the original add — not everyone who has wanted the title since. The full wanters list lives in the Requests inbox (why[]), which never reaches the Arr.

Before queuing, the request pass reconciles the missing pool against the Arrs (one bulk fetch each, failing open on error): a title Sonarr/Radarr already tracks is dropped, since it is not really “missing”, just not imported into Plex yet. Matched on tmdbId for movies and tvdbId for shows (the candidate’s TVDB id is resolved once and reused for the send). A title on an Arr import-exclusion list (usually a past delete) is kept but flagged (excluded on GET /api/requests) and never auto-sent, so the inbox can warn that approving it is a no-op until the exclusion is removed in the Arr. A sent title records the Arr’s titleSlug (arr_slug on GET /api/requests) so the Sent log deep-links straight to its Sonarr/Radarr page; each candidate also carries TMDB’s poster_path ("/abc.jpg", or "" when TMDB has no artwork). A path and not a URL, because the image host and size buckets are TMDB’s to change, so the web UI builds the URL itself and draws a placeholder tile when the path is empty. It also carries TMDB’s synopsis (overview, "" when TMDB has none or the row predates the field), so an unfamiliar title can be judged in the inbox; both ride in the same TMDB list response, so neither costs an extra call, and both backfill on the next run that re-surfaces the title. Clear (POST /api/requests/clear) hides a sent entry via a hidden flag without deleting the tombstone that stops a still-downloading title being re-requested.

All endpoints except /api/system/health require the owner session; mutations require the x-shortlist-csrf: 1 header.

Programmatic access (API token). For scripting, generate an owner token in Settings → Advanced → API access (or POST /api/system/api-token) and send it as Authorization: Bearer <token>. It grants the same owner-level access as the browser session and needs no CSRF header (a browser never sends it automatically). The token is stored encrypted at rest (Fernet, like the Plex/AI-provider keys) and stays revealable to the owner. The Settings card and GET /api/system/api-token show it (owner-gated) so you can copy it any time; it never appears in GET /api/settings. Regenerating or revoking (DELETE /api/system/api-token) invalidates the old token immediately.

curl -H "Authorization: Bearer <token>" https://<host>/api/runs

Files under /config

shortlist.db (SQLite: settings, users, runs, restriction snapshots, and the durable plex-account-id → slug map a row’s label is built from) · secret.key (Fernet, 600) · session.secret · logs/.

Edit this page on GitHub