Add design spec for TypeScript/OOP rewrite of sugarr

This commit is contained in:
2026-08-12 19:48:03 +02:00
parent 1167d66585
commit 16e61a77e6

View File

@@ -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<TResponse, TParams = void>` declares `method`,
`path(params)`, optional `query(params)` / `body(params)`, and
`parseResponse(json): TResponse`.
- `abstract class ApiClient` holds `baseUrl` and default headers, exposes
`protected request<T>(endpoint: Endpoint<T, P>, params?: P): Promise<T>`
which builds the request, performs `fetch`, checks status codes, and
delegates response parsing to the endpoint. A protected hook
(`protected buildHeaders(): Record<string,string>`) 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<TInput, TOutput> {
protected abstract readonly promptName: string; // Langfuse prompt identifier
protected abstract buildVariables(input: TInput, ctx: RunContext): Record<string, unknown>;
protected async preProcess(input: TInput, ctx: RunContext): Promise<TInput> { return input; }
protected async postProcess(output: TOutput, input: TInput, ctx: RunContext): Promise<TOutput> { return output; }
protected abstract parseOutput(raw: string, ctx: RunContext): TOutput;
async runAsync(input: TInput, ctx: RunContext): Promise<TOutput> {
// 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<TInput, TOutput> extends LlmTool<TInput, TOutput>`
adds `protected abstract readonly outputSchema: ZodSchema<TOutput>`.
`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<TInput> extends LlmTool<TInput, string>`
implements `parseOutput` as a trimmed passthrough of the raw text — for
tools that don't need a JSON contract.
Concrete: `MovieRecommenderTool extends SchemaLlmTool<RecommendInput, MovieSuggestion[]>`,
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<promptName, { prompt, config, fetchedAt }>`.
- `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<TInput, TOutput> {
protected abstract runInternal(input: TInput, ctx: RunContext): Promise<TOutput>;
async runAsync(input: TInput): Promise<TOutput> {
// opens one Langfuse trace for the whole run, builds RunContext{trace},
// calls runInternal(input, ctx), flushes the trace, returns result
}
protected runTool<I, O>(tool: LlmTool<I, O>, input: I, ctx: RunContext): Promise<O> {
// runs a tool as a nested span under ctx.trace, same trace id as the pipeline
}
}
```
Concrete: `RecommendationPipeline extends LlmPipeline<RecommendationRequest, Recommendation[]>`
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).