Reference
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) |
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 |
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. |
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). Empty = Plex’s default order (rows land last, under a co-managing tool like Kometa). Re-applied at end of each run; 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.freshness |
0.5 |
REFRESH CADENCE, not a nightly shuffle: 0 = frozen once built, 1 = rebuild every night, in between = every N days (0.5 ≈ weekly). On a refresh 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 refreshes nightly (a row naming a recent watch cannot be allowed to lag behind it) and where the editor hides the control accordingly. |
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 14 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.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 |
auto |
how the llm_web source searches: native (the provider’s own web-search tool — Claude/GPT/Gemini only), exa (the Exa search API — every provider, including a local server), or auto (the default: the provider’s own tool AND Exa unioned when both are set up — they surface mostly different titles — else whichever one) |
trakt.client_id |
— | Trakt API key; required for the trakt source; encrypted |
exa.apikey |
— | Exa web-search API key; powers the llm_web source for any provider (the only web-search path for a local model); encrypted |
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. 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 |
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. Only sync.history and backup.take are read-only; 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 Jobs → Timeline, or the Jobs page frequency picker |
sync.watch_incremental |
true |
read only what changed since the last sync instead of every watched title, per user, per library, every night. Done by ORDERING (sort=lastViewedAt:desc, then stop at the first title older than the cursor), because a lastViewedAt>= query filter is silently ignored by PMS 1.43.3 — it returns the full set with a 200 (measured; see tests/fixtures/pms_watched_incremental.xml.txt). A complete read still happens every sync.watch_full_days regardless: an incremental read cannot see an un-watch or a deletion. false = always read everything |
sync.watch_full_days |
7 |
how often the COMPLETE watch-history re-read happens, in days (1–90). It is the only thing that can notice a title being un-watched or removed, so it is not optional — only its frequency is. If a library cannot be read incrementally at all, that library falls back to a complete read on its own rather than serving a stale set |
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 |
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 clearing the box 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”) |
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 |
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.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.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.min_votes |
100 |
vote-count floor on the chosen source |
requests.min_demand |
1 |
request only titles wanted by ≥ N distinct people |
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 |
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 |
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) |
API
The interactive API docs are off by default (they’d disclose the whole surface unauthenticated);
set SHORTLIST_ENABLE_DOCS=1 to expose /api/docs and /api/openapi.json for local development
(also required if you regenerate the frontend API types with pnpm -C web gen:api against a live
server). Highlights:
POST /api/auth/pin · GET /api/auth/pin/{id} · GET /api/auth/session · POST /api/auth/logout
Sign-in is owner-only in BOTH states. Once claimed, the Plex account must match the linked server's owner (403 otherwise). Before it is claimed there is no owner to compare against, so plex.tv is asked directly: an account that owns no Plex server is refused (403) rather than handed a session — someone who merely has a share on your server can never sign in. If plex.tv can't be reached — or answers with something that isn't a resources list — the sign-in fails closed with 503, never open. **Known gap:** on an unclaimed instance the test is "owns *a* Plex server", not "owns *this* one", so someone running their own PMS can still claim an instance that has credentials seeded from the environment but no server linked yet. Link your server as step 1 and the window closes
POST /api/setup/probe · POST /api/setup/link · GET/PUT /api/setup/state
`/link` verifies with plex.tv that `machine_id` is a server the caller's account OWNS (403 otherwise). The body's `owner_account_id` is only the caller vouching for themselves — `/servers` lists shared servers alongside owned ones, so without the plex.tv check a friend with a share could link your PMS and become this instance's owner. The wizard shows shared servers but won't let you select one
GET /api/users · PATCH /api/users/{id} {enabled?, request_tag?, prefs?} · POST /api/users/sync (shared + Home users from plex.tv, plus the server owner, whom that list never returns)
POST /api/users/set-enabled {enabled} (bulk enable/disable every user at once)
GET /api/users/{id}/rows · PUT /api/users/{id}/rows/{collection_id} {muted?, row_size?, recent_count?} (per-person, per-row: `recent_count` (1–25) overrides how many recent watches the `llm_web` source searches for this person on this row; null on any field clears it back to the row's own setting)
GET /api/users/{id}/runs (this person's outcome per run — `status`, `reason` for a non-failing skip, `duration_ms`, their diff and picks) · GET /api/users/{id}/runs/summary -> {included, total} (a run is server-wide, so "6 runs" on a person's page only reads honestly next to "of 148")
GET /api/users/search/titles?q=&media_type=movie|show -> [{tmdb_id, title, media_type, year}] (TMDB's best guess, for the block-a-seed picker)
POST /api/users/{id}/blocked-seeds {tmdb_id, title?, media_type?, year?} · DELETE /api/users/{id}/blocked-seeds/{tmdb_id} -> {blocked_seeds: [{tmdb_id, title, media_type, year}]}
Titles that must never SEED this person's recommendations — the watch stays in their history, it just stops shaping their picks.
Stored on `users.prefs`; an install that predates the richer shape holds bare TMDB ids and keeps working unchanged.
GET /api/users/{id}/history (recent watches; each item carries `title`, `media_type`, `year`, plus `season`/`episode`/`episode_title` for TV)
GET/POST /api/collections · PATCH/DELETE /api/collections/{id} (incl. `request_tag`, `candidate_sources`, `library_keys`, `max_seeds` — how many watched titles the row is built from (1–100; null inherits the engine default of 30), `seed_window` — how many recent watches a one-title row cycles between, one per run (1–20, default 1 = always their most recent; no global to inherit), `pick_order` — how the delivered collection is ordered (`best` | `rating` | `newest` | `shuffle`, default `best`), `hub_anchor` — per-row shelf-placement override, and `poster` — custom row artwork {mode: ""|upload|generate, title, subtitle, style})
GET /api/collections/{id}/effectiveness -> {delivered, watched, first_delivered_at, matured_days, matured, per_library} (has this row actually landed? `matured` is null until picks are old enough to judge — a pick counts as a hit only if watched within 30 days, so a newer row is reported as "too early" rather than scored 0%)
`rewatch` (bool, default false) makes a REWATCH row: already-finished titles are ordered FIRST and unwatched ones only fill what is left.
`watched_pct` cannot express this — it is a ceiling, so the ranking shows unwatched titles first and merely PERMITS finished ones; even at 1.0 a
library with plenty of unwatched candidates yields a mostly-unwatched row. Setting `rewatch` also keeps finished titles in the row's candidate
POOL regardless of `watched_pct`, so the two rows do not share one pool.
`unstarted_only` (bool, default false, SHOWS only) drops every series the person has started, however little of it. The normal filter only drops
shows they have FINISHED (`recommendations.watched_show_pct`), so one they are three episodes into is otherwise still eligible — this is what makes
"a series to start" literally true. Meaningless for movies, where any view is already a finish.
Both are refused (422) in combinations that cannot work: `rewatch` + `unstarted_only` together (they ask for opposite things — the row would fill
with titles nobody has seen, under a "you've already seen" name), and `unstarted_only` on a `media: "movie"` row. PATCH validates the MERGED row,
not just the fields sent, so neither invalid pair can be reached one field at a time.
POST /api/collections/{id}/cleanup {dry_run?} (remove this row's Plex collections for everyone; dry-run previews)
POST /api/collections/{id}/poster/upload (multipart image) · GET/DELETE /api/collections/{id}/poster/image (serve/remove uploaded artwork) · POST /api/collections/{id}/poster/preview {title,subtitle,style} -> generated sample image
GET /api/system/image-provider -> {capable, provider, reason} (can the AI provider generate poster images — drives the row editor's Generate gate)
GET /api/system/logs?level=&q=&limit= (parsed + redacted log lines) · GET /api/system/logs/download (all log files, redacted, as a zip)
GET /api/system/libraries -> [{key, title, type}] (the server's Plex libraries, for the row editor)
GET /api/system/jobs?kind=&limit=&before_id= -> [{id, kind, payload, result, status, attempts, max_attempts, detail, error, created_at, started_at, finished_at}] (background maintenance history, newest first; `kind` narrows it to one job type, which is how the Jobs page shows a single job's own history; runs have their own page)
GET /api/schedule -> {jobs[{kind, label, setting, cron, optional, writes_plex, next_run}], rows[{cron, rows[], next_run}]} (everything on a timer, rows grouped by shared cron exactly as the scheduler groups them — one trigger builds all of them). Read-only: crons are still edited through PUT /api/settings and PATCH /api/collections, so each one is validated in exactly one place
GET /api/system/jobs/catalog -> [{kind, label, description, manual, trigger, scheduled, next_run, last, total, queued, running, failed}] (every job Shortlist can run, with its schedule, its tallies and its most recent run — the Jobs page renders straight from this, so labels can't drift from the handler registry)
POST /api/system/jobs {kind, payload?, background?} -> the job after an inline drain, or as soon as it is queued when `background` is set (the Jobs page uses that and polls, so a slow job can't end in a proxy timeout that reads as a failure). Only `sync.users`, `sync.history`, `sync.check`, `privacy.sync`, `backup.take` and `maintenance.prune` (retention trim of old runs, picks, log lines and expired caches — touches Plex not at all) may be triggered by hand — all of them converge-to-desired-state passes that take no target. The rest are queued by the mutation that knows their target and are rejected here with 422: `user.cleanup` (someone turned off), `user.hide`/`user.restore` (paused/un-paused), `row.reconcile` (a row deleted, switched off, narrowed to fewer libraries, or with someone dropped from its audience). Queued work waits for any run in progress, is retried with backoff, survives a restart, and raises a notification if it gives up
GET /api/system/libraries/{key}/collections -> [{title}] (a library's managed collections — anchor choices for row placement, excludes Shortlist's own)
GET /api/system/owned-collections -> {collections:[{library,title,label,rating_key,kind,slug,orphan}], total, orphans} (cleanup audit: every shortlist-labelled collection ON PLEX, drift-flagged, DB-independent)
GET /api/runs?limit=&collection=&before_id= (newest first; `before_id` pages backwards) · GET /api/runs/summary · GET /api/runs/{id} (each user carries `status`, `error`, `reason` — why a `skipped` user built nothing — and `has_trace`) · GET /api/runs/{id}/users/{user_id}/trace -> {username, display_name, status, error, reason, trace, breakdown} (the full per-user pipeline trace — history (with true distinct-title watched totals per library, split by media type) / seeds with each seed's weight ingredients, each source's queries+returns tagged with their fate (kept / already_watched / not_in_your_libraries / excluded_genre / lost_ranking_cutoff), the web-search/RAG prompts, resolved vs. hallucinated titles (the AI's resolved proposals carry the same fate so the UI marks each kept vs. dropped), plus `error`/`reason` for a failed or skipped person and `breakdown` (the delivered picks per library); a cold-start user carries a trace too (their thin history + a synthetic `cold_start` source), so `has_trace` is set and the "How we picked" page renders for them; fetched on demand, `trace: {}` on runs predating the feature) · GET /api/runs/{id}/log?after_seq=&format=json|text (the run's activity feed, kept in `run_log_lines` so an older run still has one; `after_seq` returns only what is new, `format=text` is the download) · POST /api/runs {user_ids?, collection_ids?, dry_run?} · POST /api/runs/{id}/cancel · DELETE /api/runs (clear all run history; changes nothing on Plex, and no longer disarms the row reconciles — they address collections by label + rendered title, not by run history)
GET /api/requests · GET /api/requests/status -> {request_id: "downloaded"|"downloading"|"queued"|"unmonitored"|null} (live Sonarr/Radarr status for WAITING and SENT items — rejected are skipped; null = neither app tracks it; fetched separately so the list itself makes no Arr calls, and read from whole-library maps so the cost doesn't scale with inbox size) · POST /api/requests/send {ids, dry_run?} · POST /api/requests/reject {ids} (permanent) · POST /api/requests/restore {ids} (un-reject → back to Waiting) · POST /api/requests/delete {ids} (removable; can re-surface) · POST /api/requests/clear {ids} (hide SENT items from the log without un-sending — the tombstone stays so the title isn't re-requested)
GET /api/events (SSE) · GET /api/events/log?scope=&limit=&before_id= (audit feed; `before_id` pages backwards — a cursor rather than an offset, since events are appended while you read)
GET /api/notifications -> {items[]} · POST /api/notifications/dismiss {id} (dismiss one alert)
GET/PUT /api/settings · POST /api/settings/test/{plex|tautulli|tmdb|llm|radarr|sonarr|mdblist|trakt|exa} (a PUT that changes anything also writes a `settings.change` audit event carrying `{key: {from, to}}` for the changed keys only — secrets record `<redacted>` on both sides, and long object values are summarised; read it back with `/api/events/log?scope=settings.change` to see which thresholds a past run actually used)
GET /api/settings/arr/{radarr|sonarr}/options -> {quality_profiles, root_folders}
POST /api/settings/curator/models {provider?, api_key?, ollama_url?} -> {provider, models[]} (models the provider offers; the body lets the picker list the provider being edited before it is saved — blank fields fall back to saved settings, a redacted key means "use the saved key"; [] = free-text fallback)
GET /api/report?window=7|30|90|all -> {window, since, first_pick, overall, trend[], per_user[], per_row[], recent[], watch_sync, coverage, runs, requests, top_titles} (what got watched, from picks.watched_at)
Windowed, default 30 days, with each headline figure carried alongside its previous equal period so the UI can show a change.
`requests.watched_after_sent` compares a watch against `request_candidates.sent_at`, stamped once when the status flips
to "sent" (rows predating that column fall back to `updated_at`). It used to be an unordered set intersection, which
counted a title watched BEFORE it was ever requested.
`first_pick` is the oldest pick on record (null when there are none). On a young install every window already covers all the data, so 7/30/90/all
return identical numbers and the selector looks broken; the UI compares `first_pick` against `since` to say why rather than leaving it a mystery.
`overall.landing` is the one RATIO, and it is computed over a MATURED cohort: picks delivered in the window AND at least 30 days
old. That matters — a pick can only ever be credited as watched within 30 days of delivery, so counting a pick delivered
yesterday in the denominator drags the rate toward zero for no reason. `per_user`/`per_row` return COUNTS, not rates, sorted by
what was actually watched: at these sample sizes a percentage is noise, and sorting by one put `1/31` above `3/103`.
POST /api/report/sync -> 202 (kick off a watch-history sync — re-reads every user's watched set from Plex so hit rates and "N titles watched" stay fresh between runs; writes nothing to Plex)
GET /api/report/deleted-rows -> [{slug, picks, first_seen, last_seen}] (pick history left behind by rows that no longer exist, biggest first; NOT windowed — "what can I clear" is a question about all of it)
DELETE /api/report/deleted-rows?slug= -> {cleared, picks, slugs[]} (permanently delete that history; omit `slug` to clear every deleted row)
Eligibility is recomputed server-side from `collections` vs `picks.collection_slug`, so naming a live row's slug deletes
nothing and returns `cleared: 0` rather than erroring. The DELETE re-checks it in the same statement (`NOT EXISTS`),
which closes the window in which a row re-created between the two would be treated as an orphan.
Only `picks` rows are removed. `deliveries` is deliberately untouched — it is the ledger of which Plex collection is
which row, and clearing it would strand a real collection with nothing left to clean it up. Audited as
`report.clear_deleted_rows` with a per-slug count.
GET /api/system/health -> {status} (the ONE unauthenticated endpoint — liveness only, for Docker's HEALTHCHECK; the version lives on the owner-gated /system/version)
GET /api/system/api-token -> {enabled, created_at, token} (owner-gated; token revealable) · POST /api/system/api-token -> {token, created_at} (generate/replace) · DELETE /api/system/api-token (revoke)
GET /api/setup/servers (Plex server picker during onboarding) · GET /api/setup/state
The AI provider (curator.provider) no longer ranks a fixed candidate pool — the engine does the
diversification and writes the genre-template reasons itself. The provider’s one remaining job is 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.
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: native uses the provider’s own web-search tool (Claude,
GPT, or Gemini), exa uses the Exa search API (exa.apikey) — the only path for a local
model — and auto (the default) UNIONS the provider’s own tool and Exa when both are set up (they
surface mostly different titles, so the pool is widest), else whichever one is available. 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 — 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 button (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.
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 — 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} 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 — Plex exposes no per-viewing-user hub order.
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.
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 — 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; 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
Watched titles (and why one can still be recommended)
Shortlist excludes what someone has already watched. Each run reads every user’s complete watched set directly from your Plex server, as that user — no extra configuration, no database mount, and it works for every account on the server.
The mechanism is the per-user server token Plex already mints for every share. When you share
libraries with someone, plex.tv issues a server-scoped accessToken for their account
(GET /api/servers/{machine}/shared_servers); reading library/sections/{key}/all?unwatched=0 with
that token returns exactly the titles Plex considers watched for them — carrying their own
viewCount (movies) and viewedLeafCount/leafCount (shows). The owner isn’t shared to their own
server, so their set is read with the admin token; a managed Home profile with no share of its own is
read by briefly switching to it and exchanging for a server token (the same path the privacy system
uses).
This closes the gap that used to let watched titles reappear. Plex has two notions of “watched”: a
playback session (something was streamed) and a mark-as-watched (ticked off, or a whole
season marked, with no play). The old playback-history API returned only the former — and capped at
roughly the most recent 200 plays — so a heavy watcher’s older titles and everyone’s marks were
invisible, and already-seen films kept coming back. viewCount > 0 (what unwatched=0 filters on)
counts both, at any depth. On one real server that meant seeing all ~13k watched titles instead
of the ~1k the API reported.
A show counts as “finished” once the user has watched enough of it — viewedLeafCount against a
fraction of its episodes (80% by default), with a length-scaled floor so a long-running series a
person is genuinely deep into isn’t treated as fresh, while three episodes of a 200-episode run isn’t
treated as finished. recommendations.watched_pct then decides how much of a row (if any) may be
titles already finished — as a CEILING, never a preference: unwatched titles are shown first, and at
most that share of the row may be things already finished. A row that should LEAD with rewatches needs
the per-row rewatch flag instead. A row that should exclude series merely STARTED (not just
finished) needs unstarted_only.
Why a watched title can still appear: the read is per-run, so a title marked watched after the
last run stays eligible until the next run re-reads. Between runs, Jobs → Sync history
(POST /api/report/sync) re-reads every user’s watched set on demand — it writes nothing to Plex,
only refreshes what Shortlist knows, so hit rates and the per-user “N titles watched” count stay
current without waiting for a scheduled run.
How a pick is chosen (and why a row can be short)
Every candidate carries an affinity — how strongly the source that produced it vouched for it, 0..1:
- TMDB sets it from which endpoint suggested the title and how near the top of that list it sat
(
/recommendationsis worth more than/similar, and both decay down their list), multiplied by a genre-coherence factor: the share of the candidate’s own genres that the seed does not have. TMDB tags a medical drama simply “Drama” and so is nearly everything it suggests, so overlap alone discriminates nothing — but a suggestion also tagged “Sci-Fi & Fantasy” is measurably further away. - Sources with no ranking of their own —
tmdb_discover,trakt,llm_web— report the neutral1.0. That means “no ranking information”, not “perfect match”; they are deliberate picks rather than the tail of a list, andpre_rank’s per-source round-robin is what keeps them competing fairly.
Ranking is (1 + seed_frequency) × rating × (1 + seed_weight) × affinity, so a well-rated but
distant title no longer beats an obviously similar one.
A row is allowed to come up short. Padding a partly-filled row only draws from candidates at or
above MIN_FILLER_AFFINITY (0.35) — four genuinely-similar titles beat ten where six are filler.
When that happens the run log says so at INFO, naming the closest rejected title, so a short row
reads as the filter working rather than as a failure.
Each delivered pick records its provenance (sources, affinity, returned by GET /api/users and
the run detail) and the UI shows it under the title — “suggested by TMDB · loosely related”. At
DEBUG the run log prints the same per row: every pick with its seed, source and affinity.
How rows stay private
Each row is a Plex collection labelled shortlist_<userslug>. Every other account’s share filter
gets a label!=shortlist_<userslug> exclusion (merged into their existing filterMovies /
filterTelevision, never rebuilt), so only its owner ever sees it. The write ordering is what keeps
this leak-safe: a run delivers rows unpromoted, merges all the exclusions, and only then
promotes rows onto Home — a new row is never visible before the exclusion that hides it exists. Rows
Plex cannot hide (wrong media type for their library) are swept away first, before anything else.
Before Shortlist first edits an account’s filters it snapshots them (restriction_snapshots), so
Uninstall restores every share exactly as it found it. The one hard requirement is a Plex Media Server
≥ 1.43.2.10687 (older builds ignore the label exclusion).
Earlier versions ran an automatic Privacy Check that verified the hiding before each write and refused to write if it couldn’t confirm it. That check + its write gate were removed at the maintainer’s request; the hiding above still happens on every run, but it is no longer verified after the fact.
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/.