mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-10 23:31:08 +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