mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-11 15:51:09 +00:00
Add design spec for TypeScript/OOP rewrite of sugarr
This commit is contained in:
@@ -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).
|
||||||
Reference in New Issue
Block a user