OpenAI's chat completions response includes prompt_tokens_details. cached_tokens when prompt caching kicks in - previously dropped entirely. Now extracted in LlmClient and forwarded to Langfuse as the flat cache_read_input_tokens usage key. Used the flat key rather than OpenAI's nested prompt_tokens_details shape: the installed @langfuse/tracing SDK's usageDetails type is Record<string, number> | OpenAiUsage (a 3-field camelCase shape, no _details nesting) even though the wire schema in @langfuse/core supports nesting - a gap in the SDK's public types, not something worth fighting with a cast. Verified: full suite (82/82) passing, tsc/eslint clean.
sugarr
Movie-recommendation companion for Jellyfin: pulls your favorite/recently-watched movies, gets suggestions from an LLM (via Langfuse-managed prompts) or a TMDb similarity/frequency fallback, resolves them against TMDb, excludes anything already in your library or Radarr, and can auto-request the rest via Radarr. Runs on a cron schedule and/or as an HTTP API.
Built with a TypeScript/OOP architecture (Node + Fastify + Langfuse): a
reusable src/core/ framework (HTTP client base classes, an LLM tool/pipeline
abstraction with Langfuse-cached prompts, Fastify server scaffolding) and an
src/app/ layer with Sugarr's concrete clients/tools/pipeline/controllers.
See docs/superpowers/specs/2026-08-12-typescript-oop-rewrite-design.md and
docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md for the design
and implementation plan (this repo previously also had a Go implementation,
now removed in favor of this one).
Running the app
pnpm install
cp .env.example .env # fill in the values below
pnpm dev # or: pnpm build && pnpm start
Required environment variables
See .env.example for the full list with defaults. At minimum you need:
JELLYFIN_URL,JELLYFIN_API_KEY,JELLYFIN_USER_ID,TMDB_API_KEYLANGFUSE_PUBLIC_KEY,LANGFUSE_SECRET_KEY,LANGFUSE_BASE_URL— the app fetches its LLM prompts from Langfuse at runtime (see below), so these are required even ifLLM_ENABLED=false(only the recommend/search endpoints actually call Langfuse; if you never enable the LLM path you can point these at any reachable Langfuse instance).
Docker
docker compose up -d --build
Seeding Langfuse prompts
The app fetches two chat prompts from Langfuse by name — movie-recommender
and movie-search — compiles them with per-request variables, and reads
model/temperature/etc. from each prompt version's config field. Ready-to-import
definitions are in langfuse/:
langfuse/movie-recommender.prompt.jsonlangfuse/movie-search.prompt.json
Create each one in your Langfuse project (UI: Prompts → New prompt, type
Chat; or via the Langfuse API/SDK) using the prompt array as the chat
messages and the config object as the prompt's config. Variables use
{{name}} interpolation:
movie-recommender:count,favorites,watched,library,previouslySuggestedmovie-search:query,count
Prompt caching and invalidation
Compiled prompts are cached in memory per process (LANGFUSE_PROMPT_CACHE_ENABLED,
default true; set to false to always fetch fresh from Langfuse — useful
while iterating on a prompt). To invalidate the cache after updating a prompt
in Langfuse without restarting the app, call:
POST /webhooks/langfuse/invalidate
Content-Type: application/json
Authorization: Bearer <SECRET_KEY> # if SECRET_KEY is set
{"promptName": "movie-recommender"} # or {} / omit body to clear the whole cache
This isn't wired to a Langfuse-native push webhook (self-hosted Langfuse has no built-in "prompt updated" event) — call it manually, from CI after a prompt change, or from Langfuse Cloud's webhook feature if you have one configured.
HTTP API
| Method | Path | Notes |
|---|---|---|
| GET | /health |
exempt from auth |
| GET | /recommendations |
?count=, ?force_llm=true, ?force_tmdb=true |
| POST | /recommendations/search |
body {"query": "..."}, requires LLM_ENABLED=true |
| POST | /recommendations/trigger |
runs the pipeline + auto-requests via Radarr if enabled |
| POST | /recommendations/request |
body {"tmdb_id": 12345}, requires RADARR_ENABLED=true |
| POST | /webhooks/langfuse/invalidate |
body {"promptName"?: string} |
If SECRET_KEY is set, all routes except /health require
Authorization: Bearer <SECRET_KEY>.