Skip to the content.

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

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:

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/.