From 16e61a77e6c26e90f8ae90d4bb3966f1df798218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Wed, 12 Aug 2026 19:48:03 +0200 Subject: [PATCH] Add design spec for TypeScript/OOP rewrite of sugarr --- ...026-08-12-typescript-oop-rewrite-design.md | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-12-typescript-oop-rewrite-design.md diff --git a/docs/superpowers/specs/2026-08-12-typescript-oop-rewrite-design.md b/docs/superpowers/specs/2026-08-12-typescript-oop-rewrite-design.md new file mode 100644 index 0000000..d9a043d --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-typescript-oop-rewrite-design.md @@ -0,0 +1,258 @@ +# Sugarr TypeScript/OOP Rewrite — Design + +**Date:** 2026-08-12 +**Branch:** `feature/typescript-rewrite` +**Status:** Approved for planning + +## Background + +Sugarr is currently a Go application (`cmd/`, `internal/`) that recommends movies: +it pulls favorite/recently-watched movies from Jellyfin, gets suggestions either +from an OpenAI-compatible LLM or a TMDb similarity/frequency fallback, resolves +suggestions against TMDb, excludes anything already in the Jellyfin library or +Radarr, optionally auto-requests via Radarr, and exposes this via a cron job and +an HTTP API (`/health`, `/recommendations`, `/recommendations/search`, +`/recommendations/trigger`, `/recommendations/request`), with bearer-token auth. + +The Go implementation stays untouched during this work. This spec covers a +parallel Node/TypeScript rewrite with a reusable, OOP "core" architecture +(intended to be copyable into future projects) and an "app" layer with +Sugarr's specific tools/pipelines/clients. Prompts and per-prompt model +options move out of hardcoded Go strings and into Langfuse, cached in memory +with a webhook-triggered invalidation path. + +Cutover (deleting the Go code, promoting the Node Dockerfile/compose) happens +in a later pass once the TS version reaches functional parity — out of scope +for this spec. + +## Goals + +- Full OOP core: `ApiClient`/`Endpoint`, `LlmTool` (+ `SchemaLlmTool` / + `TextLlmTool`), `LlmPipeline`. +- Langfuse-backed prompts (text + per-prompt `config` for model/temperature/etc), + in-memory cached, with an env flag to disable caching and a webhook endpoint + to invalidate the cache. +- Functional parity with the current Go app's recommendation pipeline and HTTP + API surface. +- Folder structure that cleanly separates reusable `core/` from Sugarr-specific + `app/` code. +- Runs in Docker, independent of the existing Go Dockerfile/compose. + +## Non-goals + +- Changing the public HTTP route shapes/methods/bodies (kept identical to Go, + plus one new webhook route). +- Removing or modifying the Go implementation. +- Standing up a new Langfuse instance — an existing instance is assumed + reachable via env-configured URL/keys. +- Persistent storage / database — the app remains stateless, same as today. + +## Repo layout + +``` +sugarr/ + cmd/, internal/, Dockerfile, docker-compose.yml # existing Go app, untouched + src/ + core/ # reusable, copyable to future projects + config/ env.ts # zod-based env loader helper + logging/ logger.ts # pino logger factory + http/ ApiClient.ts, Endpoint.ts + langfuse/ PromptService.ts, PromptCache.ts, types.ts + llm/ LlmClient.ts, LlmTool.ts, SchemaLlmTool.ts, TextLlmTool.ts, + LlmPipeline.ts, types.ts (RunContext, etc.) + server/ Controller.ts, buildServer.ts, authHook.ts + app/ # sugarr-specific + config/ AppConfig.ts + clients/ + jellyfin/ JellyfinClient.ts, endpoints/*.ts + tmdb/ TmdbClient.ts, endpoints/*.ts + radarr/ RadarrClient.ts, endpoints/*.ts + tools/ MovieRecommenderTool.ts + pipelines/ RecommendationPipeline.ts + controllers/RecommendationsController.ts, RequestController.ts, + HealthController.ts, WebhookController.ts + cron.ts + index.ts # entrypoint: config, cron, Fastify server + test/ # vitest, mirrors src/ + Dockerfile.node + docker-compose.node.yml + package.json, tsconfig.json, .env.example +``` + +At cutover time (future, out of scope here): rename `Dockerfile.node` → +`Dockerfile`, merge compose files, delete `cmd/`, `internal/`, `go.mod`, +`go.sum`. + +## Core architecture + +### `ApiClient` / `Endpoint` + +- `abstract class Endpoint` declares `method`, + `path(params)`, optional `query(params)` / `body(params)`, and + `parseResponse(json): TResponse`. +- `abstract class ApiClient` holds `baseUrl` and default headers, exposes + `protected request(endpoint: Endpoint, params?: P): Promise` + which builds the request, performs `fetch`, checks status codes, and + delegates response parsing to the endpoint. A protected hook + (`protected buildHeaders(): Record`) lets subclasses inject + per-client auth (Jellyfin token header, Radarr API key header, TMDb query + param). +- App clients (`JellyfinClient`, `TmdbClient`, `RadarrClient`) extend + `ApiClient`; each exposes typed methods that construct the relevant + `Endpoint` and call `request()`. + +### `LlmTool` hierarchy + +```ts +interface RunContext { + trace: LangfuseTraceHandle; + logger: Logger; +} + +abstract class LlmTool { + protected abstract readonly promptName: string; // Langfuse prompt identifier + protected abstract buildVariables(input: TInput, ctx: RunContext): Record; + protected async preProcess(input: TInput, ctx: RunContext): Promise { return input; } + protected async postProcess(output: TOutput, input: TInput, ctx: RunContext): Promise { return output; } + protected abstract parseOutput(raw: string, ctx: RunContext): TOutput; + + async runAsync(input: TInput, ctx: RunContext): Promise { + // 1. fetch+compile prompt+config via PromptService (cached) + // 2. input = await preProcess(input, ctx) + // 3. call LlmClient.chat(messages, config) inside a Langfuse generation + // nested under ctx.trace + // 4. output = parseOutput(rawText, ctx) + // 5. output = await postProcess(output, input, ctx) + // 6. log via app logger + Langfuse generation metadata; return output + } +} +``` + +Two abstract subclasses implement `parseOutput`: + +- `abstract class SchemaLlmTool extends LlmTool` + adds `protected abstract readonly outputSchema: ZodSchema`. + `parseOutput` strips markdown code fences, extracts the JSON + array/object (same recovery approach as today's `parseMovieSuggestions`), + parses it, and validates against `outputSchema`, throwing a descriptive + error (recorded on the trace) on failure. +- `abstract class TextLlmTool extends LlmTool` + implements `parseOutput` as a trimmed passthrough of the raw text — for + tools that don't need a JSON contract. + +Concrete: `MovieRecommenderTool extends SchemaLlmTool`, +with `outputSchema = z.array(z.object({ title: z.string(), year: z.number(), reason: z.string() }))`. + +### `PromptService` (Langfuse prompt cache) + +- Wraps the Langfuse SDK's prompt-fetch call. In-memory + `Map`. +- `LANGFUSE_PROMPT_CACHE_ENABLED` env var (default `true`); when `false`, + every fetch bypasses the cache and goes straight to Langfuse. +- `invalidate(promptName?: string)` clears one entry, or the whole cache when + no name is given. +- Exposed via `WebhookController`: `POST /webhooks/langfuse/invalidate` with + body `{ promptName?: string }`, protected by the same bearer secret as the + rest of the API. This is designed to be called manually, from CI, or from + an external trigger (Langfuse Cloud webhook, if configured) — Langfuse + self-host has no built-in "prompt updated" push, so this is a + pull-invalidation surface, not a guaranteed auto-sync. + +### `LlmPipeline` + +```ts +abstract class LlmPipeline { + protected abstract runInternal(input: TInput, ctx: RunContext): Promise; + + async runAsync(input: TInput): Promise { + // opens one Langfuse trace for the whole run, builds RunContext{trace}, + // calls runInternal(input, ctx), flushes the trace, returns result + } + + protected runTool(tool: LlmTool, input: I, ctx: RunContext): Promise { + // runs a tool as a nested span under ctx.trace, same trace id as the pipeline + } +} +``` + +Concrete: `RecommendationPipeline extends LlmPipeline` +owns what `Recommender.RunWithCount` does today: + +1. Fetch favorites; if `favorites.length < MIN_RATED_MOVIES`, fall back to + recently-watched as the signal set. +2. Build the exclusion set from the full Jellyfin library plus (if Radarr is + enabled) Radarr's existing movies. +3. Retry loop (bounded by `RECOMMEND_MAX_RETRY`): each attempt asks for the + remaining needed count, either via `MovieRecommenderTool.runAsync` (LLM + enabled) or the TMDb similarity/frequency fallback (LLM disabled); + resolves each suggestion via TMDb search; accumulates non-excluded results; + the running list of resolved suggestions across attempts feeds back into + the tool's `buildVariables` so retries don't repeat themselves. All under + one trace id. +4. `autoRequest(recommendations)` — separate method, called by the cron job + and the `/recommendations/trigger` route (not by plain `GET /recommendations`), + matching today's behavior — adds each recommendation to Radarr. + +## HTTP layer (Fastify) + +Routes, identical to Go, plus the new webhook: + +| Method | Path | Notes | +|---|---|---| +| GET | `/health` | exempt from auth | +| GET | `/recommendations` | `?count=`, `?force_llm=true`, `?force_tmdb=true` | +| POST | `/recommendations/search` | body `{ query: string }` | +| POST | `/recommendations/trigger` | runs pipeline + auto-request if Radarr enabled | +| POST | `/recommendations/request` | body `{ tmdb_id: number }` | +| POST | `/webhooks/langfuse/invalidate` | body `{ promptName?: string }` — new | + +- `abstract class Controller { abstract register(app: FastifyInstance): void }`. +- Bearer auth as a Fastify `onRequest` hook, secret from `API_SECRET_KEY` + env var, `/health` exempt — same semantics as `httputil.AuthMiddleware`. + +## Config, logging, LLM client + +- `AppConfig`: zod schema over `process.env` (loaded via `dotenv`). Same env + var names as the Go app (`JELLYFIN_URL`, `JELLYFIN_API_KEY`, + `JELLYFIN_USER_ID`, `TMDB_API_KEY`, `LLM_ENABLED`, `LLM_BASE_URL`, + `LLM_API_KEY`, `LLM_MODEL`, `RADARR_ENABLED`, `RADARR_URL`, + `RADARR_API_KEY`, `RADARR_QUALITY_PROFILE_ID`, `RADARR_ROOT_FOLDER`, + `CRON_ENABLED`, `CRON_SCHEDULE`, `API_ENABLED`, `API_PORT`, `SECRET_KEY`, + `RECOMMEND_COUNT`, `RECOMMEND_MAX_RETRY`, `MIN_RATED_MOVIES`), plus new + `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_BASE_URL`, + `LANGFUSE_PROMPT_CACHE_ENABLED`. +- `logger.ts`: pino, structured JSON logging, replaces zerolog's console + writer with an equivalent pretty-print transport in development. +- `LlmClient` (`core/llm`): thin wrapper over the `openai` npm SDK pointed at + `LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL` — works against Ollama, LM Studio, + or OpenAI itself, same as the current Go client. Called from inside + `LlmTool.runAsync`, wrapped in a Langfuse generation. +- Cron: `node-cron`, driven by `CRON_ENABLED`/`CRON_SCHEDULE`, calling + `RecommendationPipeline.runAsync` then `autoRequest` if Radarr is enabled — + same as the Go `main.go` cron branch. + +## Testing + +- Vitest for unit tests. +- `core`: `LlmTool`/`SchemaLlmTool`/`TextLlmTool` orchestration (mocked + `PromptService`/`LlmClient`), `PromptService` cache hit/miss/invalidate/ + disabled-cache behavior, `ApiClient` request building. +- `app`: `RecommendationPipeline` retry/exclusion logic, TMDb-fallback + ranking, client response parsing (Jellyfin item → `Movie` mapping, TMDb + search → `TMDbMovie` mapping, Radarr add/lookup). + +## Tooling + +- pnpm, Node (per CLAUDE.md environment, Node 25.6.1 available). +- `tsx` for dev-run, `tsc` for production build. +- ESLint + Prettier. +- `Dockerfile.node` (multi-stage: install → build → slim runtime image) and + `docker-compose.node.yml` (mirrors the existing compose file's env var + wiring, adds the four new `LANGFUSE_*` vars), kept separate from the Go + Dockerfile/compose until cutover. + +## Open questions + +None outstanding — all resolved during brainstorming (runtime/package +manager, HTTP framework, Langfuse hosting, Go-code disposition, prompt +config mechanism, test framework, API-surface scope).