| title | Reference: settings |
|---|---|
| description | Every Shortlist settings key with its default, grouped by area, per-row request overrides, and what changes on Plex the moment you save one. |
| heading | Settings reference |
| updated | 2026-10-03 |
Settings has three tabs, each with its own address:
- Connections (
/settings/connections): Plex, TMDB, AI and web search, Tautulli, Trakt, MDBList, Overseerr or Jellyseerr, Radarr, Sonarr, and the webhook with its alert events. - Defaults (
/settings/defaults): Title sources, Refresh and variety, Row defaults, Row placement and Requests. - System (
/settings/system): retention, console log detail, run concurrency, the Plex request timeout, the Plex cleanup audit, API access and the Danger zone.
The search box finds a setting by name and jumps to it. Disabled users see nothing is on the
Privacy page, and the search box points you there. Old #hash links such as /settings#defaults or
/settings#api-access still work: they open the tab that now holds that section.
Common refresh and variety controls are visible under Refresh and variety; More recommendation controls contains the additional watch-history and rating controls. Links to a specific control reveal its group automatically. All forms stay mounted, so switching sections keeps unfinished edits.
Each form keeps its existing save behavior. Automatic saves show progress, confirmation or an error near that section; connection forms still use their explicit save and test controls. In the Defaults tab, number fields accept a complete draft before applying bounds when you leave the field or press Enter. Replacing or revoking an API token asks for confirmation because existing scripts lose access immediately.
Container environment variables, serving from a subpath, and the files under /config are in
Environment and files.
Grouped by the part of Shortlist they belong to. Every key has its own link: #key- followed by the key with dots as dashes, such as #key-requests-min-rating.
Plex, Tautulli and the services titles come from.
| 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. |
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 |
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 |
The optional AI model and the web search behind the llm_web source.
| Key | Default | Notes |
|---|---|---|
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 |
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 |
llm_web.instructions |
"" |
server-wide instructions for AI web search, up to 2000 characters. Empty means Shortlist's built-in guidance. A row can use it, add to it or replace it (see AI instructions). {count}, {year} and {last_year} fill in; any other brace is sent as typed. Changing it rebuilds every row that uses it on its next run |
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. |
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 |
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.
The default row, shelf order, and how titles are found and ranked.
| Key | Default | Notes |
|---|---|---|
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 that can build in a library the default row reaches is already titled from the same template — two rows that render one title in one library become one collection on Plex, since per-person rows share a label and are told apart by title within a library. Rows that can never meet in a library may share a title. The same check guards creating, renaming, or changing the libraries, media type or build of 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 |
— | Removed; placement is per row. Where a row sits is set on the row (Rows → open a row → Where it sits), per library: top of the shelf (the default), right after/before a collection you pick, right after/before another Shortlist row, or not positioned at all. This key is no longer read or written, and an old value left in the database is ignored. It went because it was a second place to set the same thing and disagreed with its own screen: "Wherever Plex puts them" wrote no entry, and no entry anywhere meant top of the shelf — while the moment one library was configured, every other one silently meant leave alone |
rows.manage_shelf_order |
true |
master switch for Shortlist touching the Recommended-shelf ORDER at all. true (default) applies each row's own placement at the end of every run; false = never reorder the shelf, leaving the order entirely to a co-managing tool like agregarr/Kometa. "Never reorder" is not "leave rows where they are": Plex appends a new collection to the END of the shelf, so new rows start at the bottom while this is off |
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 |
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 |
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) |
| Key | Default | Notes |
|---|---|---|
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. |
Radarr, Sonarr and Overseerr requests. Rows can override most of these; see below.
| Key | Default | Notes |
|---|---|---|
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 = file as the API key's own account — 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: requests are filed as that account (X-API-User), so its own approval, request permission and quota apply. 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. |
| Key | Default | Notes |
|---|---|---|
notify.webhook.enabled |
false |
send alerts to a webhook at all |
notify.webhook.url |
— | the webhook address. Secret: encrypted at rest, shown as dots |
notify.webhook.events |
["run.failed", "privacy.exposure"] |
which events are sent: run.started, run.finished, run.partial, run.failed, run.stopped, job.started, job.finished, job.failed, privacy.exposure, requests.waiting, update.available. See Outgoing notifications |
notify.webhook.auth_header_name |
Authorization |
the header the value below is sent in. Blank sends no header |
notify.webhook.auth_header_value |
— | optional key sent with every webhook POST, e.g. Bearer …. Secret: encrypted at rest, shown as dots |
Cron expressions for the background jobs. Empty means the default shown.
| Key | Default | Notes |
|---|---|---|
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.users_cron |
"" (daily 04:47) |
cron expression for the user-list sync schedule. Blank = built-in default |
privacy.sync_cron |
"" (every 30 minutes) |
cron for the privacy sync. A re-merge of every account's share filter, which also picks up accounts newly shared with the server. 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) and seasons (collections.seasons). 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 and no seasonal row has opened or closed a season in the last seven nights; where one has, the pass is a share-filter merge plus the ~5ms visibility flips, idempotent rather than tracked in state |
sync.check_cron |
"" (daily 05:45) |
cron for the drift check — after the rows build (03:30) and the 05:30 privacy pass, 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 |
themes.rotate_cron |
"" (daily 01:30) |
cron for Pick new row themes: for every AI row set to Explore, moves each person to their next theme when the current one has run its course, and writes the following theme a day early. Asks your AI provider only when a theme is needed. Changes nothing on Plex; a row picks up its new theme the next time it builds. Like sync.check_cron, it can be switched off entirely (Jobs → Pick new row themes → Off), which stores an empty value; Built-in deletes the stored value so the default applies again |
| Key | Default | Notes |
|---|---|---|
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 current delivery records are kept, so watched-pick counts, future watch tracking and Plex row cleanup continue). 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 → System → "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_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 |
paused_all |
false |
Danger-Zone "stop all runs" switch; pauses without disabling anyone |
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.
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.
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.
Seasons (collections.seasons, season_lead_days, season_after_days) hide a row outside its
seasons the same way — placement off, collection kept — and, inside a season, show_days narrows it
further. The season a row builds for is resolved when the run's context is built: the season it shows
today or, on the night before one opens, that season, so the row is filled while still hidden and the
midnight pass shows a fresh row. A seasonal row with no show_days takes a midnight pass only for the
seven nights after its shown-state changes (a season opening or closing), not every night — a week rather
than one night, so a pass that fails, is deferred behind a run, or is skipped while everything is paused
is still redone. Each run of the row keeps it right in between. See Seasonal rows.
collections.seasons holds the slugs of the seasons a row follows: the built-ins valentines,
halloween and christmas, plus any season of your own. Your own seasons are rows of the seasons
table, not settings, and are managed through /api/seasons:
| Column | Holds |
|---|---|
slug, name, emoji |
the season's identity; the slug is unique, never changes and is what collections.seasons holds |
rule_kind, month, day, nth, weekday, easter_offset |
its date: a fixed day, the nth or last weekday of a month, or a day counted from Easter |
lead_days, after_days |
its own window (default 7 before, 0 after); built-ins use the row's season_lead_days/season_after_days instead |
tags, genre, excluded_genres |
TMDB keywords, one optional genre, and genres to leave out |
collections, picks |
Plex collections (read only, matched by title) and films picked by hand |
preset |
the ready-made season it was added from, if any |
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, which is no longer acted on at all — a row with no Position set
already sits at the top, so the flag said nothing new, and it worked by asking Plex to move the row
to the very top, which is the one move that can collapse a library's shelf order (see Why every move
goes to the bottom in the Rows guide). pin_top is still accepted and returned by
POST/PATCH /api/collections so existing clients keep working, and the row editor converts it into
a per-library Top the first time you save that row — but nothing reads it, so an old value left in
the database changes nothing on Plex. 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.
A relation between two rows is only honoured when Shortlist is placing both of them — it cannot hold one row against another it never moves. So if the row you named has its own Position switched off, or has nothing in that library yet, the following row falls back to the top of the shelf, in your Rows order. Not "left where it is": Plex appends new hubs at the bottom, so a new row nothing positions starts out of sight.
A foreign anchor has to be on one of that library's Plex shelves. A collection — or one of
Plex's own built-in rows — switched off in the library's Manage Recommendations screen occupies no
position anyone can see, so there is nothing to sit next to. Only the rows pointed at that anchor are
affected: they keep their current place, every other row in the library is still positioned, and the
events feed names the anchor that could not be used so you can tell which setting is doing nothing.
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 title that could never
land is not auto-sent either — it waits in the inbox with the reason, so it cannot take a
max_per_run slot every night: a movie or show whose Radarr/Sonarr is not fully set up, and a show
TMDB has no TVDB id for. The last applies on the Overseerr route too, because Overseerr hands shows to
Sonarr by TVDB id and deletes a request it cannot map. 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 → System →
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