33 Commits

Author SHA1 Message Date
f26a5d72b2 Add Docker packaging for the TypeScript app 2026-08-12 21:30:32 +02:00
31de7f15db Wire up cron scheduling and the app entrypoint 2026-08-12 21:23:08 +02:00
e712470013 Add Health/Recommendations/Request/Webhook controllers 2026-08-12 21:21:26 +02:00
3c5f723d22 Isolate per-item Radarr failures in RecommendationPipeline.autoRequest
Matches the Go original's behavior: a lookup/addMovie failure for one
recommendation is logged and skipped rather than aborting the whole
auto-request batch.
2026-08-12 21:18:33 +02:00
62848f6ecd Add RecommendationPipeline 2026-08-12 21:15:21 +02:00
5edeb0ebb9 Add MovieRecommenderTool and MovieSearchTool 2026-08-12 21:14:10 +02:00
5140bc5a50 Widen loadEnv schema type and allow rest-sibling destructuring in lint
loadEnv's ZodSchema<T> parameter required schema input === output,
which breaks any schema using .transform() (e.g. AppConfigSchema's
boolean coercion). Widen to ZodType<T, ZodTypeDef, unknown>, matching
existing runtime behavior. Also allow ignoreRestSiblings in the
no-unused-vars rule so 'const { X, ...rest } = obj' omission tests
don't trip the linter.
2026-08-12 21:08:21 +02:00
8e908408cd Add RadarrClient 2026-08-12 21:05:58 +02:00
4e483557d7 Add TmdbClient 2026-08-12 21:05:22 +02:00
9683d65a1f Add JellyfinClient 2026-08-12 21:04:47 +02:00
ef037d25fe Add app config schema and shared models 2026-08-12 21:04:15 +02:00
69db1d5558 Harden auth hook and fix eslint config for intentional unused args
- authHook: use crypto.timingSafeEqual for the bearer token comparison
  instead of !==, avoiding a timing side-channel on the secret key.
- .eslintrc.cjs: add argsIgnorePattern for underscore-prefixed unused
  params, fixing false-positive no-unused-vars errors on intentional
  _ctx/_params placeholders used throughout the LlmTool hierarchy.
2026-08-12 21:00:44 +02:00
bb479f7749 Fix LangfusePromptGateway compile() to match langfuse SDK's Record<string,string> variable type 2026-08-12 20:56:39 +02:00
cf3c12774f Add concrete Langfuse tracer and prompt gateway 2026-08-12 20:55:51 +02:00
299375bcd3 Add core server Controller base, auth hook, and buildServer 2026-08-12 20:55:06 +02:00
3f99bce3c6 Add core LlmPipeline base class 2026-08-12 20:54:38 +02:00
8d468903a4 Add SchemaLlmTool and TextLlmTool 2026-08-12 20:54:10 +02:00
ee4aba83e8 Add core Tracer/RunContext types and LlmTool base class 2026-08-12 20:53:27 +02:00
3ca58194e6 Add core LlmClient wrapping OpenAI-compatible chat completions 2026-08-12 20:52:36 +02:00
81e9a20b14 Add cached Langfuse PromptService 2026-08-12 20:52:10 +02:00
580357a130 Add core Endpoint/ApiClient base classes 2026-08-12 20:51:36 +02:00
ca08ed9294 Add core env loader 2026-08-12 20:51:03 +02:00
a9a8fa7c3f Scaffold TypeScript project and add core logger 2026-08-12 20:50:40 +02:00
abfbc1e2da Add implementation plan for TypeScript/OOP rewrite 2026-08-12 20:35:35 +02:00
16e61a77e6 Add design spec for TypeScript/OOP rewrite of sugarr 2026-08-12 19:48:03 +02:00
1167d66585 fix error 2026-04-25 21:27:15 +02:00
58a2b00e3d add version 2026-04-25 21:21:44 +02:00
8e37ff2ed6 add missing env to compose 2026-04-25 21:16:13 +02:00
5dd8902472 retry if no new suggestions 2026-04-25 21:07:15 +02:00
abb228052c remove .env 2026-04-25 15:22:43 +02:00
c5135f3190 update gitignore 2026-04-25 15:22:21 +02:00
b6ad5a5121 secret key to config 2026-04-25 15:21:22 +02:00
846083e230 trigger endpoint and retry suggestions 2026-04-25 15:21:09 +02:00
83 changed files with 10318 additions and 67 deletions

30
.env
View File

@@ -1,30 +0,0 @@
# Required
JELLYFIN_URL=http://jellyfin.internal
JELLYFIN_API_KEY=4e8416e25e404688a202fec082af21fc
JELLYFIN_USER_ID=182031712c9240eb91f571003f2d3ba5
TMDB_API_KEY=ad49c6908213b3aae1788825df8a08d1
# LLM (disabled by default — works with Ollama, OpenAI, LM Studio, etc.)
LLM_ENABLED=true
LLM_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
LLM_API_KEY=AIzaSyCPSXWsWw78n_Wn4GOXMAyruAz5-05gF3Q
LLM_MODEL=gemini-2.5-flash
# Radarr (disabled by default)
RADARR_ENABLED=true
RADARR_URL=http://radarr.internal
RADARR_API_KEY=3f0e10ad239f4be5b33abcdf06f6e373
RADARR_QUALITY_PROFILE_ID=1
RADARR_ROOT_FOLDER=/movies
# Cron auto-run (disabled by default)
CRON_ENABLED=true
CRON_SCHEDULE=0 3 * * *
# HTTP API (enabled by default)
API_ENABLED=true
API_PORT=8080
# Tuning
RECOMMEND_COUNT=20
MIN_RATED_MOVIES=5

38
.env.example Normal file
View File

@@ -0,0 +1,38 @@
# Required
JELLYFIN_URL=
JELLYFIN_API_KEY=
JELLYFIN_USER_ID=
TMDB_API_KEY=
# LLM (disabled by default — works with Ollama, OpenAI, LM Studio, etc.)
LLM_ENABLED=false
LLM_BASE_URL=http://localhost:11434/v1
LLM_API_KEY=
LLM_MODEL=llama3
# Radarr (disabled by default)
RADARR_ENABLED=false
RADARR_URL=
RADARR_API_KEY=
RADARR_QUALITY_PROFILE_ID=1
RADARR_ROOT_FOLDER=/movies
# Cron auto-run (disabled by default)
CRON_ENABLED=false
CRON_SCHEDULE=0 3 * * *
# HTTP API (enabled by default)
API_ENABLED=true
API_PORT=8080
SECRET_KEY=
# Tuning
RECOMMEND_COUNT=10
RECOMMEND_MAX_RETRY=3
MIN_RATED_MOVIES=5
# Langfuse (required by the TypeScript app)
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
LANGFUSE_BASE_URL=
LANGFUSE_PROMPT_CACHE_ENABLED=true

15
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,15 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
env: { node: true, es2022: true },
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
ignorePatterns: ['dist', 'node_modules'],
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', ignoreRestSiblings: true },
],
},
};

42
.gitignore vendored
View File

@@ -1 +1,43 @@
# ---> Go
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
vendor
bin
.vscode
.idea
.env
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.prof
*.log
*.db
# .Dockerfile
.zed
deploy.config
node_modules/
dist/
coverage/

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"printWidth": 100,
"trailingComma": "es5"
}

17
Dockerfile.node Normal file
View File

@@ -0,0 +1,17 @@
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --ignore-scripts
COPY tsconfig.json ./
COPY src ./src
RUN pnpm build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
COPY --from=build /app/dist ./dist
ENTRYPOINT ["node", "dist/app/index.js"]

View File

@@ -14,6 +14,8 @@ import (
"github.com/rs/zerolog/log"
)
const version = "0.0.1"
func main() {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
@@ -72,6 +74,7 @@ func main() {
func printBanner(cfg *configs.Config) {
log.Info().Msg("sugarr starting")
log.Info().Msgf(" Version: %s", version)
log.Info().Msgf(" Jellyfin: %s (user: %s)", cfg.JellyfinURL, cfg.JellyfinUserID)
log.Info().Msgf(" LLM: enabled=%v model=%s", cfg.LLMEnabled, cfg.LLMModel)
log.Info().Msgf(" Radarr: enabled=%v", cfg.RadarrEnabled)

40
docker-compose.node.yml Normal file
View File

@@ -0,0 +1,40 @@
services:
sugarr-node:
build:
context: .
dockerfile: Dockerfile.node
container_name: sugarr-node
restart: unless-stopped
ports:
- "${API_PORT}:${API_PORT}"
environment:
JELLYFIN_URL: ${JELLYFIN_URL}
JELLYFIN_API_KEY: ${JELLYFIN_API_KEY}
JELLYFIN_USER_ID: ${JELLYFIN_USER_ID}
TMDB_API_KEY: ${TMDB_API_KEY}
LLM_ENABLED: ${LLM_ENABLED}
LLM_BASE_URL: ${LLM_BASE_URL}
LLM_MODEL: ${LLM_MODEL}
LLM_API_KEY: ${LLM_API_KEY}
RADARR_ENABLED: ${RADARR_ENABLED}
RADARR_URL: ${RADARR_URL}
RADARR_API_KEY: ${RADARR_API_KEY}
RADARR_QUALITY_PROFILE_ID: ${RADARR_QUALITY_PROFILE_ID}
RADARR_ROOT_FOLDER: ${RADARR_ROOT_FOLDER}
CRON_ENABLED: ${CRON_ENABLED}
CRON_SCHEDULE: ${CRON_SCHEDULE}
API_ENABLED: ${API_ENABLED}
API_PORT: ${API_PORT}
SECRET_KEY: ${SECRET_KEY}
RECOMMEND_COUNT: ${RECOMMEND_COUNT}
RECOMMEND_MAX_RETRY: ${RECOMMEND_MAX_RETRY}
MIN_RATED_MOVIES: ${MIN_RATED_MOVIES}
LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY}
LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY}
LANGFUSE_BASE_URL: ${LANGFUSE_BASE_URL}
LANGFUSE_PROMPT_CACHE_ENABLED: ${LANGFUSE_PROMPT_CACHE_ENABLED}
networks:
- media
networks:
media:

View File

@@ -25,6 +25,8 @@ services:
API_PORT: ${API_PORT}
RECOMMEND_COUNT: ${RECOMMEND_COUNT}
MIN_RATED_MOVIES: ${MIN_RATED_MOVIES}
SECRET_KEY: ${SECRET_KEY}
RECOMMEND_MAX_RETRY: ${RECOMMEND_MAX_RETRY}
networks:
- media

File diff suppressed because it is too large Load Diff

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).

View File

@@ -119,12 +119,14 @@ func parseMovieSuggestions(content string) ([]model.MovieSuggestion, error) {
}
// BuildPrompt constructs the recommendation prompt sent to the LLM.
func BuildPrompt(favorites, watched, library []model.Movie, count int) string {
// previouslySuggested contains movies returned in prior retry attempts that must not be repeated.
func BuildPrompt(favorites, watched, library, previouslySuggested []model.Movie, count int) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf(
"You are a movie recommendation engine. Based on the user's favorite and recently watched movies below, suggest exactly %d movies they would enjoy.\n\n"+
"IMPORTANT:\n"+
"- Do NOT suggest any movie from the \"Already In Library\" list.\n"+
"- Do NOT suggest any movie from the \"Already Suggested\" list.\n"+
"- Return ONLY a JSON array, no other text.\n"+
"- Each entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}\n\n"+
"User's favorite movies:\n", count))
@@ -150,5 +152,13 @@ func BuildPrompt(favorites, watched, library []model.Movie, count int) string {
for _, m := range library {
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
}
if len(previouslySuggested) > 0 {
sb.WriteString("\nAlready suggested (DO NOT repeat these):\n")
for _, m := range previouslySuggested {
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
}
}
return sb.String()
}

View File

@@ -21,6 +21,7 @@ func NewRecommendationsController(mux *http.ServeMux, cfg *configs.Config, rec *
c := &RecommendationsController{cfg: cfg, rec: rec}
mux.HandleFunc("GET /recommendations", c.handleList)
mux.HandleFunc("POST /recommendations/search", c.handleSearch)
mux.HandleFunc("POST /recommendations/trigger", c.handleTrigger)
return c
}
@@ -53,6 +54,21 @@ func (c *RecommendationsController) handleList(w http.ResponseWriter, r *http.Re
httputil.WriteJSON(w, http.StatusOK, recs)
}
// handleTrigger runs the same pipeline as the cron job: recommend + auto-request if Radarr is enabled.
func (c *RecommendationsController) handleTrigger(w http.ResponseWriter, r *http.Request) {
log.Info().Msg("Trigger: running recommendation pipeline")
recs, err := c.rec.Run()
if err != nil {
log.Error().Err(err).Msg("Trigger: pipeline failed")
httputil.WriteError(w, http.StatusInternalServerError, err.Error())
return
}
if c.cfg.RadarrEnabled {
c.rec.AutoRequest(recs)
}
httputil.WriteJSON(w, http.StatusOK, recs)
}
// handleSearch performs a natural-language LLM search and resolves results via TMDb.
// Body: {"query": "sci-fi movies like Blade Runner but more recent"}
func (c *RecommendationsController) handleSearch(w http.ResponseWriter, r *http.Request) {

View File

@@ -79,42 +79,62 @@ func (r *Recommender) RunWithCount(count int) ([]model.Recommendation, error) {
}
}
// 3. Get raw suggestions
var suggestions []model.MovieSuggestion
if r.cfg.LLMEnabled && r.llm != nil {
prompt := client.BuildPrompt(favorites, watchedMovies, library, count)
suggestions, err = r.llm.Recommend(prompt)
if err != nil {
return nil, fmt.Errorf("llm recommendation failed: %w", err)
}
log.Info().Msgf("LLM returned %d suggestions", len(suggestions))
} else {
suggestions = r.tmdbFallback(signalMovies, exclude, count)
log.Info().Msgf("TMDb fallback returned %d suggestions", len(suggestions))
}
// 4. Resolve suggestions via TMDb and apply exclusions
// 3. Retry loop: each attempt requests only the remaining needed count.
// `seen` accumulates every TMDb-resolved movie across attempts so the
// LLM prompt excludes them by name, not just by ID.
var recommendations []model.Recommendation
for _, s := range suggestions {
if len(recommendations) >= count {
var seen []model.Movie
for attempt := 0; attempt <= r.cfg.RecommendMaxRetry && len(recommendations) < count; attempt++ {
remaining := count - len(recommendations)
if attempt > 0 {
log.Info().Msgf("Retry %d/%d: need %d more recommendations", attempt, r.cfg.RecommendMaxRetry, remaining)
}
var suggestions []model.MovieSuggestion
if r.cfg.LLMEnabled && r.llm != nil {
prompt := client.BuildPrompt(favorites, watchedMovies, library, seen, remaining)
suggestions, err = r.llm.Recommend(prompt)
if err != nil {
return recommendations, fmt.Errorf("llm recommendation failed: %w", err)
}
log.Info().Msgf("LLM returned %d suggestions (attempt %d)", len(suggestions), attempt+1)
} else {
suggestions = r.tmdbFallback(signalMovies, exclude, remaining)
log.Info().Msgf("TMDb fallback returned %d suggestions (attempt %d)", len(suggestions), attempt+1)
}
if len(suggestions) == 0 {
log.Warn().Msgf("No suggestions returned on attempt %d, stopping retries", attempt+1)
break
}
tm, err := r.tmdb.SearchMovie(s.Title, s.Year)
if err != nil || tm == nil {
log.Debug().Msgf("Could not resolve: %s (%d)", s.Title, s.Year)
continue
before := len(recommendations)
for _, s := range suggestions {
if len(recommendations) >= count {
break
}
tm, err := r.tmdb.SearchMovie(s.Title, s.Year)
if err != nil || tm == nil {
log.Debug().Msgf("Could not resolve: %s (%d)", s.Title, s.Year)
continue
}
seen = append(seen, model.Movie{Name: tm.Title, Year: tm.Year, TMDbID: strconv.Itoa(tm.ID)})
if exclude[tm.ID] {
log.Debug().Msgf("Skipping (already in library): %s", tm.Title)
continue
}
exclude[tm.ID] = true
recommendations = append(recommendations, model.Recommendation{
Title: tm.Title,
Year: tm.Year,
TMDbID: tm.ID,
Reason: s.Reason,
})
}
if exclude[tm.ID] {
log.Debug().Msgf("Skipping (already in library): %s", tm.Title)
continue
if len(recommendations) == before {
log.Info().Msgf("All suggestions on attempt %d were already in library, retrying", attempt+1)
}
exclude[tm.ID] = true
recommendations = append(recommendations, model.Recommendation{
Title: tm.Title,
Year: tm.Year,
TMDbID: tm.ID,
Reason: s.Reason,
})
}
return recommendations, nil

View File

@@ -25,11 +25,13 @@ type Config struct {
CronEnabled bool `envconfig:"CRON_ENABLED" default:"false"`
CronSchedule string `envconfig:"CRON_SCHEDULE" default:"0 3 * * *"`
APIEnabled bool `envconfig:"API_ENABLED" default:"true"`
APIPort int `envconfig:"API_PORT" default:"8080"`
APIEnabled bool `envconfig:"API_ENABLED" default:"true"`
APIPort int `envconfig:"API_PORT" default:"8080"`
APISecretKey string `envconfig:"SECRET_KEY" default:""`
RecommendCount int `envconfig:"RECOMMEND_COUNT" default:"10"`
MinRatedMovies int `envconfig:"MIN_RATED_MOVIES" default:"5"`
RecommendCount int `envconfig:"RECOMMEND_COUNT" default:"10"`
RecommendMaxRetry int `envconfig:"RECOMMEND_MAX_RETRY" default:"3"`
MinRatedMovies int `envconfig:"MIN_RATED_MOVIES" default:"5"`
}
// Load reads .env (best-effort) then processes environment variables into a Config.

View File

@@ -3,6 +3,7 @@ package httputil
import (
"encoding/json"
"net/http"
"strings"
)
func WriteJSON(w http.ResponseWriter, status int, v any) {
@@ -14,3 +15,21 @@ func WriteJSON(w http.ResponseWriter, status int, v any) {
func WriteError(w http.ResponseWriter, status int, msg string) {
WriteJSON(w, status, map[string]string{"error": msg})
}
// AuthMiddleware validates the Authorization: Bearer <key> header.
// If secretKey is empty, auth is disabled and all requests pass through.
// /health is always exempt.
func AuthMiddleware(secretKey string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if secretKey == "" || r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || token != secretKey {
WriteError(w, http.StatusUnauthorized, "unauthorized")
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/fjurmanovic/sugarr/internal/api"
"github.com/fjurmanovic/sugarr/internal/service"
"github.com/fjurmanovic/sugarr/internal/utl/configs"
"github.com/fjurmanovic/sugarr/internal/utl/httputil"
"github.com/rs/zerolog/log"
)
@@ -17,5 +18,8 @@ func Start(cfg *configs.Config, rec *service.Recommender) error {
addr := fmt.Sprintf(":%d", cfg.APIPort)
log.Info().Msgf("HTTP API listening on %s", addr)
return http.ListenAndServe(addr, mux)
if cfg.APISecretKey != "" {
log.Info().Msg("Secret key auth enabled")
}
return http.ListenAndServe(addr, httputil.AuthMiddleware(cfg.APISecretKey, mux))
}

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "sugarr",
"version": "0.1.0",
"private": true,
"main": "dist/app/index.js",
"scripts": {
"dev": "tsx watch src/app/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/app/index.js",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src test --ext .ts",
"format": "prettier --write ."
},
"dependencies": {
"dotenv": "^16.4.5",
"fastify": "^5.0.0",
"langfuse": "^3.30.0",
"node-cron": "^3.0.3",
"openai": "^4.60.0",
"pino": "^9.4.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.7.4",
"@types/node-cron": "^3.0.11",
"@typescript-eslint/eslint-plugin": "^8.8.0",
"@typescript-eslint/parser": "^8.8.0",
"eslint": "^8.57.0",
"pino-pretty": "^11.2.2",
"prettier": "^3.3.3",
"tsx": "^4.19.1",
"typescript": "^5.6.2",
"vitest": "^2.1.1"
}
}

3013
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
import { ApiClient } from '../../../core/http/ApiClient';
import type { Movie } from '../../models';
import { ItemsEndpoint } from './endpoints/ItemsEndpoint';
export class JellyfinClient extends ApiClient {
private readonly endpoint = new ItemsEndpoint();
constructor(
baseUrl: string,
private readonly apiKey: string,
private readonly userId: string
) {
super(baseUrl);
}
protected buildHeaders(): Record<string, string> {
return {
...super.buildHeaders(),
Authorization: `MediaBrowser Token="${this.apiKey}"`,
};
}
getFavoriteMovies(): Promise<Movie[]> {
return this.request(this.endpoint, {
userId: this.userId,
query: {
IncludeItemTypes: 'Movie',
Recursive: 'true',
IsFavorite: 'true',
Fields: 'ProviderIds,Genres,CommunityRating,UserData',
},
});
}
getRecentlyWatched(): Promise<Movie[]> {
return this.request(this.endpoint, {
userId: this.userId,
query: {
IncludeItemTypes: 'Movie',
Recursive: 'true',
Filters: 'IsPlayed',
SortBy: 'DatePlayed',
SortOrder: 'Descending',
Limit: '50',
Fields: 'ProviderIds,Genres,CommunityRating,UserData',
},
});
}
getAllMovies(): Promise<Movie[]> {
return this.request(this.endpoint, {
userId: this.userId,
query: {
IncludeItemTypes: 'Movie',
Recursive: 'true',
Fields: 'ProviderIds',
Limit: '10000',
},
});
}
}

View File

@@ -0,0 +1,46 @@
import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint';
import type { Movie } from '../../../models';
interface JellyfinItem {
Name: string;
ProductionYear: number;
ProviderIds: Record<string, string>;
Genres: string[];
CommunityRating: number;
UserData: { IsFavorite: boolean; PlayCount: number };
}
interface JellyfinItemsResponse {
Items: JellyfinItem[];
}
export interface ItemsParams {
userId: string;
query: Record<string, string>;
}
export class ItemsEndpoint extends Endpoint<Movie[], ItemsParams> {
readonly method: HttpMethod = 'GET';
path(params: ItemsParams): string {
return `/Users/${params.userId}/Items`;
}
query(params: ItemsParams): Record<string, string> {
return params.query;
}
parseResponse(json: unknown): Movie[] {
const response = json as JellyfinItemsResponse;
return response.Items.map((item) => ({
name: item.Name,
year: item.ProductionYear,
imdbId: item.ProviderIds?.Imdb ?? '',
tmdbId: item.ProviderIds?.Tmdb ?? '',
genres: item.Genres ?? [],
communityRating: item.CommunityRating ?? 0,
isFavorite: item.UserData?.IsFavorite ?? false,
playCount: item.UserData?.PlayCount ?? 0,
}));
}
}

View File

@@ -0,0 +1,45 @@
import { ApiClient } from '../../../core/http/ApiClient';
import { LookupEndpoint, type RadarrMovie } from './endpoints/LookupEndpoint';
import { AddMovieEndpoint } from './endpoints/AddMovieEndpoint';
import { GetExistingEndpoint } from './endpoints/GetExistingEndpoint';
export type { RadarrMovie } from './endpoints/LookupEndpoint';
export class RadarrClient extends ApiClient {
private readonly lookupEndpoint = new LookupEndpoint();
private readonly addMovieEndpoint = new AddMovieEndpoint();
private readonly getExistingEndpoint = new GetExistingEndpoint();
constructor(
baseUrl: string,
private readonly apiKey: string,
private readonly qualityProfileId: number,
private readonly rootFolder: string
) {
super(baseUrl);
}
protected buildHeaders(): Record<string, string> {
return { ...super.buildHeaders(), 'X-Api-Key': this.apiKey };
}
async lookup(tmdbId: number): Promise<RadarrMovie> {
const result = await this.request(this.lookupEndpoint, { tmdbId });
return {
title: result.title,
tmdbId: result.tmdbId,
qualityProfileId: this.qualityProfileId,
rootFolderPath: this.rootFolder,
monitored: true,
addOptions: { searchForMovie: true },
};
}
addMovie(movie: RadarrMovie): Promise<void> {
return this.request(this.addMovieEndpoint, movie);
}
getExisting(): Promise<RadarrMovie[]> {
return this.request(this.getExistingEndpoint, undefined);
}
}

View File

@@ -0,0 +1,16 @@
import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint';
import type { RadarrMovie } from './LookupEndpoint';
export class AddMovieEndpoint extends Endpoint<void, RadarrMovie> {
readonly method: HttpMethod = 'POST';
readonly acceptableStatuses = [400];
path(): string {
return '/api/v3/movie';
}
body(params: RadarrMovie): unknown {
return params;
}
parseResponse(): void {
return undefined;
}
}

View File

@@ -0,0 +1,12 @@
import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint';
import type { RadarrMovie } from './LookupEndpoint';
export class GetExistingEndpoint extends Endpoint<RadarrMovie[], void> {
readonly method: HttpMethod = 'GET';
path(): string {
return '/api/v3/movie';
}
parseResponse(json: unknown): RadarrMovie[] {
return json as RadarrMovie[];
}
}

View File

@@ -0,0 +1,32 @@
import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint';
export interface RadarrMovie {
title: string;
tmdbId: number;
qualityProfileId: number;
rootFolderPath: string;
monitored: boolean;
addOptions: { searchForMovie: boolean };
}
interface RadarrLookupResult {
title: string;
tmdbId: number;
}
export interface LookupParams {
tmdbId: number;
}
export class LookupEndpoint extends Endpoint<RadarrLookupResult, LookupParams> {
readonly method: HttpMethod = 'GET';
path(): string {
return '/api/v3/movie/lookup/tmdb';
}
query(params: LookupParams): Record<string, string> {
return { tmdbId: String(params.tmdbId) };
}
parseResponse(json: unknown): RadarrLookupResult {
return json as RadarrLookupResult;
}
}

View File

@@ -0,0 +1,32 @@
import { ApiClient } from '../../../core/http/ApiClient';
import type { TMDbMovie } from '../../models';
import { SearchMovieEndpoint } from './endpoints/SearchMovieEndpoint';
import { RecommendedMoviesEndpoint, SimilarMoviesEndpoint } from './endpoints/MovieListEndpoint';
const TMDB_BASE_URL = 'https://api.themoviedb.org';
export class TmdbClient extends ApiClient {
private readonly searchEndpoint = new SearchMovieEndpoint();
private readonly similarEndpoint = new SimilarMoviesEndpoint();
private readonly recommendedEndpoint = new RecommendedMoviesEndpoint();
constructor(
private readonly apiKey: string,
baseUrl: string = TMDB_BASE_URL
) {
super(baseUrl);
}
async searchMovie(title: string, year?: number): Promise<TMDbMovie | undefined> {
const results = await this.request(this.searchEndpoint, { apiKey: this.apiKey, title, year });
return results[0];
}
getSimilar(tmdbId: number): Promise<TMDbMovie[]> {
return this.request(this.similarEndpoint, { apiKey: this.apiKey, tmdbId });
}
getRecommendations(tmdbId: number): Promise<TMDbMovie[]> {
return this.request(this.recommendedEndpoint, { apiKey: this.apiKey, tmdbId });
}
}

View File

@@ -0,0 +1,44 @@
import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint';
import type { TMDbMovie } from '../../../models';
import { toTMDbMovie } from './SearchMovieEndpoint';
interface TMDbResult {
id: number;
title: string;
release_date: string;
}
interface TMDbListResponse {
results: TMDbResult[];
}
export interface MovieListParams {
apiKey: string;
tmdbId: number;
}
export class SimilarMoviesEndpoint extends Endpoint<TMDbMovie[], MovieListParams> {
readonly method: HttpMethod = 'GET';
path(params: MovieListParams): string {
return `/3/movie/${params.tmdbId}/similar`;
}
query(params: MovieListParams): Record<string, string> {
return { api_key: params.apiKey };
}
parseResponse(json: unknown): TMDbMovie[] {
return (json as TMDbListResponse).results.map(toTMDbMovie);
}
}
export class RecommendedMoviesEndpoint extends Endpoint<TMDbMovie[], MovieListParams> {
readonly method: HttpMethod = 'GET';
path(params: MovieListParams): string {
return `/3/movie/${params.tmdbId}/recommendations`;
}
query(params: MovieListParams): Record<string, string> {
return { api_key: params.apiKey };
}
parseResponse(json: unknown): TMDbMovie[] {
return (json as TMDbListResponse).results.map(toTMDbMovie);
}
}

View File

@@ -0,0 +1,41 @@
import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint';
import type { TMDbMovie } from '../../../models';
interface TMDbResult {
id: number;
title: string;
release_date: string;
}
interface TMDbSearchResponse {
results: TMDbResult[];
}
export interface SearchMovieParams {
apiKey: string;
title: string;
year?: number;
}
export function toTMDbMovie(result: TMDbResult): TMDbMovie {
const year = result.release_date && result.release_date.length >= 4 ? Number(result.release_date.slice(0, 4)) : 0;
return { id: result.id, title: result.title, year };
}
export class SearchMovieEndpoint extends Endpoint<TMDbMovie[], SearchMovieParams> {
readonly method: HttpMethod = 'GET';
path(): string {
return '/3/search/movie';
}
query(params: SearchMovieParams): Record<string, string> {
const query: Record<string, string> = { api_key: params.apiKey, query: params.title };
if (params.year) query.year = String(params.year);
return query;
}
parseResponse(json: unknown): TMDbMovie[] {
return (json as TMDbSearchResponse).results.map(toTMDbMovie);
}
}

View File

@@ -0,0 +1,53 @@
import { z } from 'zod';
import { loadEnv } from '../../core/config/env';
const boolFromEnv = (defaultValue: 'true' | 'false') =>
z
.string()
.optional()
.default(defaultValue)
.transform((v) => v === 'true');
export const AppConfigSchema = z.object({
JELLYFIN_URL: z.string(),
JELLYFIN_API_KEY: z.string(),
JELLYFIN_USER_ID: z.string(),
TMDB_API_KEY: z.string(),
LLM_ENABLED: boolFromEnv('false'),
LLM_BASE_URL: z.string().default('http://localhost:11434/v1'),
LLM_API_KEY: z.string().optional().default(''),
LLM_MODEL: z.string().default('llama3'),
RADARR_ENABLED: boolFromEnv('false'),
RADARR_URL: z.string().optional().default(''),
RADARR_API_KEY: z.string().optional().default(''),
RADARR_QUALITY_PROFILE_ID: z.coerce.number().default(1),
RADARR_ROOT_FOLDER: z.string().default('/movies'),
CRON_ENABLED: boolFromEnv('false'),
CRON_SCHEDULE: z.string().default('0 3 * * *'),
API_ENABLED: z
.string()
.optional()
.default('true')
.transform((v) => v !== 'false'),
API_PORT: z.coerce.number().default(8080),
SECRET_KEY: z.string().optional().default(''),
RECOMMEND_COUNT: z.coerce.number().default(10),
RECOMMEND_MAX_RETRY: z.coerce.number().default(3),
MIN_RATED_MOVIES: z.coerce.number().default(5),
LANGFUSE_PUBLIC_KEY: z.string(),
LANGFUSE_SECRET_KEY: z.string(),
LANGFUSE_BASE_URL: z.string(),
LANGFUSE_PROMPT_CACHE_ENABLED: boolFromEnv('true'),
});
export type AppConfig = z.infer<typeof AppConfigSchema>;
export function loadAppConfig(source: NodeJS.ProcessEnv = process.env): AppConfig {
return loadEnv(AppConfigSchema, source);
}

View File

@@ -0,0 +1,17 @@
import type { FastifyInstance } from 'fastify';
import { Controller } from '../../core/server/Controller';
import type { AppConfig } from '../config/AppConfig';
export class HealthController extends Controller {
constructor(private readonly config: AppConfig) {
super();
}
register(app: FastifyInstance): void {
app.get('/health', async () => ({
status: 'ok',
llm: this.config.LLM_ENABLED,
radarr: this.config.RADARR_ENABLED,
}));
}
}

View File

@@ -0,0 +1,81 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { Controller } from '../../core/server/Controller';
import type { AppConfig } from '../config/AppConfig';
import type { RecommendationPipeline } from '../pipelines/RecommendationPipeline';
interface ListQuery {
count?: string;
force_llm?: string;
force_tmdb?: string;
}
interface SearchBody {
query?: string;
}
export class RecommendationsController extends Controller {
constructor(
private readonly config: AppConfig,
private readonly pipeline: RecommendationPipeline
) {
super();
}
register(app: FastifyInstance): void {
app.get('/recommendations', (request, reply) => this.handleList(request as FastifyRequest<{ Querystring: ListQuery }>, reply));
app.post('/recommendations/search', (request, reply) =>
this.handleSearch(request as FastifyRequest<{ Body: SearchBody }>, reply)
);
app.post('/recommendations/trigger', (request, reply) => this.handleTrigger(request, reply));
}
private async handleList(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
const count = request.query.count ? Number(request.query.count) : this.config.RECOMMEND_COUNT;
const useLlm =
request.query.force_tmdb === 'true'
? false
: request.query.force_llm === 'true'
? true
: this.config.LLM_ENABLED;
try {
const recommendations = await this.pipeline.runAsync({ count, useLlm });
reply.send(recommendations);
} catch (err) {
reply.code(500).send({ error: String(err) });
}
}
private async handleTrigger(_request: FastifyRequest, reply: FastifyReply) {
try {
const recommendations = await this.pipeline.runAsync({
count: this.config.RECOMMEND_COUNT,
useLlm: this.config.LLM_ENABLED,
});
if (this.config.RADARR_ENABLED) {
await this.pipeline.autoRequest(recommendations);
}
reply.send(recommendations);
} catch (err) {
reply.code(500).send({ error: String(err) });
}
}
private async handleSearch(request: FastifyRequest<{ Body: SearchBody }>, reply: FastifyReply) {
if (!this.config.LLM_ENABLED) {
reply.code(400).send({ error: 'LLM is not enabled' });
return;
}
const query = request.body?.query;
if (!query) {
reply.code(400).send({ error: 'body must be {"query": "..."}' });
return;
}
try {
const recommendations = await this.pipeline.searchAsync(query, this.config.RECOMMEND_COUNT);
reply.send(recommendations);
} catch (err) {
reply.code(500).send({ error: String(err) });
}
}
}

View File

@@ -0,0 +1,42 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { Controller } from '../../core/server/Controller';
import type { AppConfig } from '../config/AppConfig';
import type { RadarrClient } from '../clients/radarr/RadarrClient';
interface RequestBody {
tmdb_id?: number;
}
export class RequestController extends Controller {
constructor(
private readonly config: AppConfig,
private readonly radarr: RadarrClient | undefined
) {
super();
}
register(app: FastifyInstance): void {
app.post('/recommendations/request', (request, reply) =>
this.handle(request as FastifyRequest<{ Body: RequestBody }>, reply)
);
}
private async handle(request: FastifyRequest<{ Body: RequestBody }>, reply: FastifyReply) {
if (!this.config.RADARR_ENABLED || !this.radarr) {
reply.code(400).send({ error: 'Radarr is not enabled' });
return;
}
const tmdbId = request.body?.tmdb_id;
if (!tmdbId) {
reply.code(400).send({ error: 'body must be {"tmdb_id": 12345}' });
return;
}
try {
const movie = await this.radarr.lookup(tmdbId);
await this.radarr.addMovie(movie);
reply.send({ requested: true, title: movie.title });
} catch (err) {
reply.code(500).send({ error: String(err) });
}
}
}

View File

@@ -0,0 +1,25 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { Controller } from '../../core/server/Controller';
import type { PromptService } from '../../core/langfuse/PromptService';
interface InvalidateBody {
promptName?: string;
}
export class WebhookController extends Controller {
constructor(private readonly promptService: PromptService) {
super();
}
register(app: FastifyInstance): void {
app.post('/webhooks/langfuse/invalidate', (request, reply) =>
this.handle(request as FastifyRequest<{ Body: InvalidateBody }>, reply)
);
}
private async handle(request: FastifyRequest<{ Body: InvalidateBody }>, reply: FastifyReply) {
const promptName = request.body?.promptName;
this.promptService.invalidate(promptName);
reply.send({ invalidated: true, promptName: promptName ?? 'all' });
}
}

26
src/app/cron.ts Normal file
View File

@@ -0,0 +1,26 @@
import cron from 'node-cron';
import type { Logger } from '../core/logging/logger';
import type { AppConfig } from './config/AppConfig';
import type { RecommendationPipeline } from './pipelines/RecommendationPipeline';
export function scheduleCron(config: AppConfig, pipeline: RecommendationPipeline, logger: Logger): void {
if (!config.CRON_ENABLED) return;
cron.schedule(config.CRON_SCHEDULE, async () => {
logger.info('Cron: running recommendation pipeline');
try {
const recommendations = await pipeline.runAsync({
count: config.RECOMMEND_COUNT,
useLlm: config.LLM_ENABLED,
});
if (config.RADARR_ENABLED) {
await pipeline.autoRequest(recommendations);
}
logger.info({ count: recommendations.length }, 'Cron: pipeline finished');
} catch (err) {
logger.error({ err }, 'Cron: pipeline failed');
}
});
logger.info({ schedule: config.CRON_SCHEDULE }, 'Cron scheduled');
}

86
src/app/index.ts Normal file
View File

@@ -0,0 +1,86 @@
import 'dotenv/config';
import { loadAppConfig } from './config/AppConfig';
import { createLogger } from '../core/logging/logger';
import { LangfuseTracer } from '../core/langfuse/LangfuseTracer';
import { LangfusePromptGateway } from '../core/langfuse/LangfusePromptGateway';
import { PromptService } from '../core/langfuse/PromptService';
import { LlmClient } from '../core/llm/LlmClient';
import { buildServer } from '../core/server/buildServer';
import { JellyfinClient } from './clients/jellyfin/JellyfinClient';
import { TmdbClient } from './clients/tmdb/TmdbClient';
import { RadarrClient } from './clients/radarr/RadarrClient';
import { MovieRecommenderTool } from './tools/MovieRecommenderTool';
import { MovieSearchTool } from './tools/MovieSearchTool';
import { RecommendationPipeline } from './pipelines/RecommendationPipeline';
import { HealthController } from './controllers/HealthController';
import { RecommendationsController } from './controllers/RecommendationsController';
import { RequestController } from './controllers/RequestController';
import { WebhookController } from './controllers/WebhookController';
import { scheduleCron } from './cron';
async function main(): Promise<void> {
const config = loadAppConfig();
const logger = createLogger('sugarr');
const langfuseOpts = {
publicKey: config.LANGFUSE_PUBLIC_KEY,
secretKey: config.LANGFUSE_SECRET_KEY,
baseUrl: config.LANGFUSE_BASE_URL,
};
const tracer = new LangfuseTracer(langfuseOpts);
const promptGateway = new LangfusePromptGateway(langfuseOpts);
const promptService = new PromptService(promptGateway, config.LANGFUSE_PROMPT_CACHE_ENABLED);
const llmClient = new LlmClient({ baseUrl: config.LLM_BASE_URL, apiKey: config.LLM_API_KEY });
const jellyfin = new JellyfinClient(config.JELLYFIN_URL, config.JELLYFIN_API_KEY, config.JELLYFIN_USER_ID);
const tmdb = new TmdbClient(config.TMDB_API_KEY);
const radarr = config.RADARR_ENABLED
? new RadarrClient(config.RADARR_URL, config.RADARR_API_KEY, config.RADARR_QUALITY_PROFILE_ID, config.RADARR_ROOT_FOLDER)
: undefined;
const recommenderTool = new MovieRecommenderTool(promptService, llmClient);
const searchTool = new MovieSearchTool(promptService, llmClient);
const pipeline = new RecommendationPipeline(tracer, logger, {
jellyfin,
tmdb,
radarr,
tool: recommenderTool,
searchTool,
minRatedMovies: config.MIN_RATED_MOVIES,
maxRetry: config.RECOMMEND_MAX_RETRY,
});
logger.info(
{
jellyfin: config.JELLYFIN_URL,
llmEnabled: config.LLM_ENABLED,
radarrEnabled: config.RADARR_ENABLED,
cronEnabled: config.CRON_ENABLED,
apiEnabled: config.API_ENABLED,
},
'sugarr starting'
);
scheduleCron(config, pipeline, logger);
if (config.API_ENABLED) {
const app = buildServer({
secretKey: config.SECRET_KEY,
controllers: [
new HealthController(config),
new RecommendationsController(config, pipeline),
new RequestController(config, radarr),
new WebhookController(promptService),
],
});
await app.listen({ port: config.API_PORT, host: '0.0.0.0' });
logger.info({ port: config.API_PORT }, 'HTTP API listening');
}
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});

29
src/app/models.ts Normal file
View File

@@ -0,0 +1,29 @@
export interface Movie {
name: string;
year: number;
imdbId: string;
tmdbId: string;
genres: string[];
communityRating: number;
isFavorite: boolean;
playCount: number;
}
export interface TMDbMovie {
id: number;
title: string;
year: number;
}
export interface MovieSuggestion {
title: string;
year: number;
reason: string;
}
export interface Recommendation {
title: string;
year: number;
tmdbId: number;
reason: string;
}

View File

@@ -0,0 +1,174 @@
import { LlmPipeline } from '../../core/llm/LlmPipeline';
import type { RunContext } from '../../core/llm/RunContext';
import type { JellyfinClient } from '../clients/jellyfin/JellyfinClient';
import type { TmdbClient } from '../clients/tmdb/TmdbClient';
import type { RadarrClient } from '../clients/radarr/RadarrClient';
import type { MovieRecommenderTool } from '../tools/MovieRecommenderTool';
import type { MovieSearchTool } from '../tools/MovieSearchTool';
import type { Movie, MovieSuggestion, Recommendation, TMDbMovie } from '../models';
import type { Tracer } from '../../core/llm/Tracer';
import type { Logger } from '../../core/logging/logger';
export interface RecommendationRequest {
count: number;
useLlm: boolean;
}
export interface RecommendationPipelineOptions {
jellyfin: JellyfinClient;
tmdb: TmdbClient;
radarr?: RadarrClient;
tool: MovieRecommenderTool;
searchTool: MovieSearchTool;
minRatedMovies: number;
maxRetry: number;
}
export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, Recommendation[]> {
protected readonly name = 'recommendation-pipeline';
constructor(
tracer: Tracer,
logger: Logger,
private readonly options: RecommendationPipelineOptions
) {
super(tracer, logger);
}
protected async runInternal(input: RecommendationRequest, ctx: RunContext): Promise<Recommendation[]> {
const favorites = await this.options.jellyfin.getFavoriteMovies();
const useFavoritesAsSignal = favorites.length >= this.options.minRatedMovies;
const watched = useFavoritesAsSignal ? [] : await this.options.jellyfin.getRecentlyWatched();
const signal = useFavoritesAsSignal ? favorites : watched;
const library = await this.options.jellyfin.getAllMovies();
const exclude = new Set<number>(toTmdbIds(library));
if (this.options.radarr) {
const existing = await this.options.radarr.getExisting();
for (const m of existing) exclude.add(m.tmdbId);
}
const recommendations: Recommendation[] = [];
const seen: Movie[] = [];
for (let attempt = 0; attempt <= this.options.maxRetry && recommendations.length < input.count; attempt++) {
const remaining = input.count - recommendations.length;
let suggestions: MovieSuggestion[];
if (input.useLlm) {
suggestions = await this.runTool(
this.options.tool,
{ favorites, watched, library, previouslySuggested: seen, count: remaining },
ctx
);
} else {
suggestions = await this.tmdbFallback(signal, exclude, remaining);
}
if (suggestions.length === 0) break;
for (const suggestion of suggestions) {
if (recommendations.length >= input.count) break;
const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year);
if (!resolved) continue;
seen.push(toSeenMovie(resolved));
if (exclude.has(resolved.id)) continue;
exclude.add(resolved.id);
recommendations.push({
title: resolved.title,
year: resolved.year,
tmdbId: resolved.id,
reason: suggestion.reason,
});
}
}
return recommendations;
}
async autoRequest(recommendations: Recommendation[]): Promise<void> {
if (!this.options.radarr) return;
for (const rec of recommendations) {
try {
const movie = await this.options.radarr.lookup(rec.tmdbId);
await this.options.radarr.addMovie(movie);
} catch (err) {
this.logger.error({ err, tmdbId: rec.tmdbId, title: rec.title }, 'Radarr auto-request failed');
}
}
}
async searchAsync(query: string, count: number): Promise<Recommendation[]> {
const trace = this.tracer.startTrace(`${this.name}-search`);
const ctx: RunContext = { trace, logger: this.logger };
try {
const library = await this.options.jellyfin.getAllMovies();
const exclude = new Set<number>(toTmdbIds(library));
const suggestions = await this.runTool(this.options.searchTool, { query, count }, ctx);
const recommendations: Recommendation[] = [];
for (const suggestion of suggestions) {
if (recommendations.length >= count) break;
const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year);
if (!resolved || exclude.has(resolved.id)) continue;
exclude.add(resolved.id);
recommendations.push({
title: resolved.title,
year: resolved.year,
tmdbId: resolved.id,
reason: suggestion.reason,
});
}
return recommendations;
} finally {
await this.tracer.flush();
}
}
private async tmdbFallback(signals: Movie[], exclude: Set<number>, count: number): Promise<MovieSuggestion[]> {
const freq = new Map<number, number>();
const meta = new Map<number, TMDbMovie>();
for (const m of signals) {
const tmdbId = Number(m.tmdbId);
if (!tmdbId) continue;
const [similar, recommended] = await Promise.all([
this.options.tmdb.getSimilar(tmdbId),
this.options.tmdb.getRecommendations(tmdbId),
]);
for (const tm of [...similar, ...recommended]) {
if (!exclude.has(tm.id)) {
freq.set(tm.id, (freq.get(tm.id) ?? 0) + 1);
meta.set(tm.id, tm);
}
}
}
const sorted = [...freq.entries()].sort((a, b) => b[1] - a[1]);
const suggestions: MovieSuggestion[] = [];
for (const [id] of sorted) {
if (suggestions.length >= count) break;
const tm = meta.get(id)!;
suggestions.push({ title: tm.title, year: tm.year, reason: 'Recommended by TMDb' });
}
return suggestions;
}
}
function toTmdbIds(movies: Movie[]): number[] {
return movies.map((m) => Number(m.tmdbId)).filter((id) => Number.isFinite(id) && id !== 0);
}
function toSeenMovie(resolved: TMDbMovie): Movie {
return {
name: resolved.title,
year: resolved.year,
imdbId: '',
tmdbId: String(resolved.id),
genres: [],
communityRating: 0,
isFavorite: false,
playCount: 0,
};
}

View File

@@ -0,0 +1,38 @@
import { z } from 'zod';
import { SchemaLlmTool } from '../../core/llm/SchemaLlmTool';
import type { RunContext } from '../../core/llm/RunContext';
import type { Movie, MovieSuggestion } from '../models';
export interface RecommendInput {
favorites: Movie[];
watched: Movie[];
library: Movie[];
previouslySuggested: Movie[];
count: number;
}
const movieSuggestionSchema = z.object({
title: z.string(),
year: z.number(),
reason: z.string(),
});
export class MovieRecommenderTool extends SchemaLlmTool<RecommendInput, MovieSuggestion[]> {
protected readonly promptName = 'movie-recommender';
protected readonly outputSchema = z.array(movieSuggestionSchema);
protected buildVariables(input: RecommendInput, _ctx: RunContext): Record<string, unknown> {
return {
count: input.count,
favorites: formatMovieList(input.favorites),
watched: formatMovieList(input.watched),
library: formatMovieList(input.library),
previouslySuggested: formatMovieList(input.previouslySuggested),
};
}
}
function formatMovieList(movies: Movie[]): string {
if (movies.length === 0) return '(none)';
return movies.map((m) => `- ${m.name} (${m.year})`).join('\n');
}

View File

@@ -0,0 +1,24 @@
import { z } from 'zod';
import { SchemaLlmTool } from '../../core/llm/SchemaLlmTool';
import type { RunContext } from '../../core/llm/RunContext';
import type { MovieSuggestion } from '../models';
export interface SearchInput {
query: string;
count: number;
}
const movieSuggestionSchema = z.object({
title: z.string(),
year: z.number(),
reason: z.string(),
});
export class MovieSearchTool extends SchemaLlmTool<SearchInput, MovieSuggestion[]> {
protected readonly promptName = 'movie-search';
protected readonly outputSchema = z.array(movieSuggestionSchema);
protected buildVariables(input: SearchInput, _ctx: RunContext): Record<string, unknown> {
return { query: input.query, count: input.count };
}
}

12
src/core/config/env.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { ZodType, ZodTypeDef } from 'zod';
export function loadEnv<T>(
schema: ZodType<T, ZodTypeDef, unknown>,
source: NodeJS.ProcessEnv = process.env
): T {
const result = schema.safeParse(source);
if (!result.success) {
throw new Error(`Invalid environment configuration: ${result.error.message}`);
}
return result.data;
}

View File

@@ -0,0 +1,47 @@
import { Endpoint } from './Endpoint';
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly body: string
) {
super(message);
this.name = 'ApiError';
}
}
export abstract class ApiClient {
protected constructor(protected readonly baseUrl: string) {}
protected buildHeaders(): Record<string, string> {
return { Accept: 'application/json' };
}
protected async request<TResponse, TParams = void>(
endpoint: Endpoint<TResponse, TParams>,
params: TParams
): Promise<TResponse> {
const query = endpoint.query(params);
const qs = query ? `?${new URLSearchParams(query).toString()}` : '';
const url = `${this.baseUrl}${endpoint.path(params)}${qs}`;
const body = endpoint.body(params);
const headers = this.buildHeaders();
const init: RequestInit = { method: endpoint.method, headers };
if (body !== undefined) {
init.body = JSON.stringify(body);
headers['Content-Type'] = 'application/json';
}
const res = await fetch(url, init);
const text = await res.text();
const ok = res.ok || endpoint.acceptableStatuses.includes(res.status);
if (!ok) {
throw new ApiError(`${endpoint.method} ${url} returned ${res.status}`, res.status, text);
}
const json = text.length > 0 ? JSON.parse(text) : undefined;
return endpoint.parseResponse(json);
}
}

18
src/core/http/Endpoint.ts Normal file
View File

@@ -0,0 +1,18 @@
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export abstract class Endpoint<TResponse, TParams = void> {
abstract readonly method: HttpMethod;
readonly acceptableStatuses: number[] = [];
abstract path(params: TParams): string;
query(_params: TParams): Record<string, string> | undefined {
return undefined;
}
body(_params: TParams): unknown {
return undefined;
}
abstract parseResponse(json: unknown): TResponse;
}

View File

@@ -0,0 +1,28 @@
import { Langfuse } from 'langfuse';
import type { PromptFetcher, RawPrompt } from './PromptGateway';
export class LangfusePromptGateway implements PromptFetcher {
private readonly client: Langfuse;
constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) {
this.client = new Langfuse({
publicKey: opts.publicKey,
secretKey: opts.secretKey,
baseUrl: opts.baseUrl,
});
}
async fetch(promptName: string): Promise<RawPrompt> {
const prompt = await this.client.getPrompt(promptName, undefined, { type: 'chat', cacheTtlSeconds: 0 });
return {
name: prompt.name,
version: prompt.version,
config: (prompt.config ?? {}) as Record<string, unknown>,
compile: (variables) =>
prompt.compile(variables as Record<string, string>) as {
role: 'system' | 'user' | 'assistant';
content: string;
}[],
};
}
}

View File

@@ -0,0 +1,40 @@
import { Langfuse } from 'langfuse';
import { Tracer } from '../llm/Tracer';
import type { GenerationHandle, GenerationInput, GenerationOutput, TraceHandle } from '../llm/Tracer';
export class LangfuseTracer extends Tracer {
private readonly client: Langfuse;
constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) {
super();
this.client = new Langfuse({
publicKey: opts.publicKey,
secretKey: opts.secretKey,
baseUrl: opts.baseUrl,
});
}
startTrace(name: string, metadata?: Record<string, unknown>): TraceHandle {
const trace = this.client.trace({ name, metadata });
return {
id: trace.id,
startGeneration(input: GenerationInput): GenerationHandle {
const generation = trace.generation({
name: input.name,
model: input.model,
input: input.input,
metadata: input.metadata,
});
return {
end(output: GenerationOutput) {
generation.end({ output: output.output, usage: output.usage, metadata: output.metadata });
},
};
},
};
}
async flush(): Promise<void> {
await this.client.flushAsync();
}
}

View File

@@ -0,0 +1,10 @@
export interface RawPrompt {
name: string;
version: number;
config: Record<string, unknown>;
compile(variables: Record<string, unknown>): { role: 'system' | 'user' | 'assistant'; content: string }[];
}
export interface PromptFetcher {
fetch(promptName: string): Promise<RawPrompt>;
}

View File

@@ -0,0 +1,46 @@
import type { PromptFetcher, RawPrompt } from './PromptGateway';
import type { CompiledPrompt } from './types';
interface CacheEntry {
raw: RawPrompt;
fetchedAt: number;
}
export class PromptService {
private readonly cache = new Map<string, CacheEntry>();
constructor(
private readonly fetcher: PromptFetcher,
private readonly cacheEnabled: boolean
) {}
async getCompiled(promptName: string, variables: Record<string, unknown>): Promise<CompiledPrompt> {
const raw = await this.getRaw(promptName);
return {
name: raw.name,
version: raw.version,
config: raw.config,
messages: raw.compile(variables),
};
}
invalidate(promptName?: string): void {
if (promptName) {
this.cache.delete(promptName);
} else {
this.cache.clear();
}
}
private async getRaw(promptName: string): Promise<RawPrompt> {
if (this.cacheEnabled) {
const cached = this.cache.get(promptName);
if (cached) return cached.raw;
}
const raw = await this.fetcher.fetch(promptName);
if (this.cacheEnabled) {
this.cache.set(promptName, { raw, fetchedAt: Date.now() });
}
return raw;
}
}

View File

@@ -0,0 +1,6 @@
export interface CompiledPrompt {
name: string;
version: number;
config: Record<string, unknown>;
messages: { role: 'system' | 'user' | 'assistant'; content: string }[];
}

50
src/core/llm/LlmClient.ts Normal file
View File

@@ -0,0 +1,50 @@
import OpenAI from 'openai';
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface ChatOptions {
model: string;
temperature?: number;
maxTokens?: number;
}
export interface ChatResult {
content: string;
usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number };
}
export class LlmClient {
private readonly client: OpenAI;
constructor(opts: { baseUrl: string; apiKey?: string }) {
this.client = new OpenAI({ baseURL: opts.baseUrl, apiKey: opts.apiKey || 'not-needed' });
}
async chat(messages: ChatMessage[], options: ChatOptions): Promise<ChatResult> {
const response = await this.client.chat.completions.create({
model: options.model,
temperature: options.temperature,
max_tokens: options.maxTokens,
messages,
});
const choice = response.choices[0];
if (!choice) {
throw new Error('LLM returned no choices');
}
return {
content: choice.message?.content ?? '',
usage: response.usage
? {
promptTokens: response.usage.prompt_tokens,
completionTokens: response.usage.completion_tokens,
totalTokens: response.usage.total_tokens,
}
: undefined,
};
}
}

View File

@@ -0,0 +1,32 @@
import type { Logger } from '../logging/logger';
import type { LlmTool } from './LlmTool';
import type { RunContext } from './RunContext';
import type { Tracer } from './Tracer';
export abstract class LlmPipeline<TInput, TOutput> {
constructor(
protected readonly tracer: Tracer,
protected readonly logger: Logger
) {}
protected abstract readonly name: string;
protected abstract runInternal(input: TInput, ctx: RunContext): Promise<TOutput>;
async runAsync(input: TInput): Promise<TOutput> {
const trace = this.tracer.startTrace(this.name);
const ctx: RunContext = { trace, logger: this.logger };
try {
return await this.runInternal(input, ctx);
} finally {
await this.tracer.flush();
}
}
protected runTool<TToolInput, TToolOutput>(
tool: LlmTool<TToolInput, TToolOutput>,
input: TToolInput,
ctx: RunContext
): Promise<TToolOutput> {
return tool.runAsync(input, ctx);
}
}

59
src/core/llm/LlmTool.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { PromptService } from '../langfuse/PromptService';
import type { ChatMessage, LlmClient } from './LlmClient';
import type { RunContext } from './RunContext';
export abstract class LlmTool<TInput, TOutput> {
protected abstract readonly promptName: string;
constructor(
protected readonly promptService: PromptService,
protected readonly llmClient: LlmClient
) {}
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> {
const processedInput = await this.preProcess(input, ctx);
const variables = this.buildVariables(processedInput, ctx);
const compiled = await this.promptService.getCompiled(this.promptName, variables);
const model = String(compiled.config.model ?? 'gpt-4o-mini');
const temperature =
typeof compiled.config.temperature === 'number' ? compiled.config.temperature : undefined;
const maxTokens =
typeof compiled.config.maxTokens === 'number' ? compiled.config.maxTokens : undefined;
const messages: ChatMessage[] = compiled.messages;
const generation = ctx.trace.startGeneration({
name: this.promptName,
model,
input: messages,
metadata: { promptVersion: compiled.version },
});
let raw: string;
try {
const result = await this.llmClient.chat(messages, { model, temperature, maxTokens });
raw = result.content;
generation.end({ output: raw, usage: result.usage });
} catch (err) {
generation.end({ output: null, metadata: { error: String(err) } });
throw err;
}
const parsed = this.parseOutput(raw, ctx);
const output = await this.postProcess(parsed, processedInput, ctx);
ctx.logger.info({ tool: this.promptName }, 'llm tool completed');
return output;
}
}

View File

@@ -0,0 +1,7 @@
import type { Logger } from '../logging/logger';
import type { TraceHandle } from './Tracer';
export interface RunContext {
trace: TraceHandle;
logger: Logger;
}

View File

@@ -0,0 +1,60 @@
import type { ZodSchema } from 'zod';
import { LlmTool } from './LlmTool';
import type { RunContext } from './RunContext';
export class LlmOutputParseError extends Error {
constructor(
message: string,
readonly raw: string
) {
super(message);
this.name = 'LlmOutputParseError';
}
}
export abstract class SchemaLlmTool<TInput, TOutput> extends LlmTool<TInput, TOutput> {
protected abstract readonly outputSchema: ZodSchema<TOutput>;
protected parseOutput(raw: string, _ctx: RunContext): TOutput {
const json = extractJson(raw);
const result = this.outputSchema.safeParse(json);
if (!result.success) {
throw new LlmOutputParseError(`Output failed schema validation: ${result.error.message}`, raw);
}
return result.data;
}
}
export function extractJson(content: string): unknown {
let text = content.trim();
const fenceStart = text.indexOf('```');
if (fenceStart !== -1) {
text = text.slice(fenceStart);
text = text.replace(/^```json/, '').replace(/^```/, '');
const fenceEnd = text.indexOf('```');
if (fenceEnd !== -1) {
text = text.slice(0, fenceEnd);
}
text = text.trim();
}
const arrayStart = text.indexOf('[');
const objectStart = text.indexOf('{');
const starts = [arrayStart, objectStart].filter((i) => i !== -1);
if (starts.length === 0) {
throw new LlmOutputParseError(`No JSON found in LLM response: ${content}`, content);
}
const start = Math.min(...starts);
const isArray = start === arrayStart;
const end = isArray ? text.lastIndexOf(']') : text.lastIndexOf('}');
if (end === -1 || end <= start) {
throw new LlmOutputParseError(`No JSON found in LLM response: ${content}`, content);
}
try {
return JSON.parse(text.slice(start, end + 1));
} catch (err) {
throw new LlmOutputParseError(`Failed to parse JSON from LLM response: ${String(err)}`, content);
}
}

View File

@@ -0,0 +1,8 @@
import { LlmTool } from './LlmTool';
import type { RunContext } from './RunContext';
export abstract class TextLlmTool<TInput> extends LlmTool<TInput, string> {
protected parseOutput(raw: string, _ctx: RunContext): string {
return raw.trim();
}
}

26
src/core/llm/Tracer.ts Normal file
View File

@@ -0,0 +1,26 @@
export interface GenerationInput {
name: string;
model: string;
input: unknown;
metadata?: Record<string, unknown>;
}
export interface GenerationOutput {
output: unknown;
usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number };
metadata?: Record<string, unknown>;
}
export interface GenerationHandle {
end(output: GenerationOutput): void;
}
export interface TraceHandle {
readonly id: string;
startGeneration(input: GenerationInput): GenerationHandle;
}
export abstract class Tracer {
abstract startTrace(name: string, metadata?: Record<string, unknown>): TraceHandle;
abstract flush(): Promise<void>;
}

View File

@@ -0,0 +1,12 @@
import pino from 'pino';
export type Logger = pino.Logger;
export function createLogger(name: string): Logger {
const isProduction = process.env.NODE_ENV === 'production';
return pino({
name,
level: process.env.LOG_LEVEL ?? 'info',
transport: isProduction ? undefined : { target: 'pino-pretty' },
});
}

View File

@@ -0,0 +1,5 @@
import type { FastifyInstance } from 'fastify';
export abstract class Controller {
abstract register(app: FastifyInstance): void;
}

View File

@@ -0,0 +1,30 @@
import { timingSafeEqual } from 'node:crypto';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) {
return false;
}
return timingSafeEqual(bufA, bufB);
}
export function registerAuthHook(
app: FastifyInstance,
secretKey: string,
exemptPaths: string[] = ['/health']
): void {
app.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
const path = request.url.split('?')[0];
if (!secretKey || exemptPaths.includes(path)) {
return;
}
const header = request.headers.authorization ?? '';
const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined;
if (token === undefined || !safeEqual(token, secretKey)) {
reply.code(401).send({ error: 'unauthorized' });
return;
}
});
}

View File

@@ -0,0 +1,17 @@
import Fastify, { type FastifyInstance } from 'fastify';
import type { Controller } from './Controller';
import { registerAuthHook } from './authHook';
export interface BuildServerOptions {
secretKey: string;
controllers: Controller[];
}
export function buildServer(options: BuildServerOptions): FastifyInstance {
const app = Fastify({ logger: false });
registerAuthHook(app, options.secretKey);
for (const controller of options.controllers) {
controller.register(app);
}
return app;
}

View File

@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JellyfinClient } from '../../../../src/app/clients/jellyfin/JellyfinClient';
describe('JellyfinClient', () => {
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
fetchMock.mockResolvedValue({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
Items: [
{
Name: 'Dune',
ProductionYear: 2021,
ProviderIds: { Imdb: 'tt1160419', Tmdb: '438631' },
Genres: ['Sci-Fi'],
CommunityRating: 8.0,
UserData: { IsFavorite: true, PlayCount: 2 },
},
],
}),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('getFavoriteMovies requests favorites and maps the response to Movie[]', async () => {
const client = new JellyfinClient('http://jellyfin', 'jf-key', 'user-1');
const movies = await client.getFavoriteMovies();
expect(movies).toEqual([
{
name: 'Dune',
year: 2021,
imdbId: 'tt1160419',
tmdbId: '438631',
genres: ['Sci-Fi'],
communityRating: 8.0,
isFavorite: true,
playCount: 2,
},
]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/Users/user-1/Items');
expect(url).toContain('IsFavorite=true');
expect(init.headers.Authorization).toBe('MediaBrowser Token="jf-key"');
});
it('getRecentlyWatched requests played items sorted by date', async () => {
const client = new JellyfinClient('http://jellyfin', 'jf-key', 'user-1');
await client.getRecentlyWatched();
const [url] = fetchMock.mock.calls[0];
expect(url).toContain('Filters=IsPlayed');
expect(url).toContain('SortBy=DatePlayed');
});
it('getAllMovies requests the full library', async () => {
const client = new JellyfinClient('http://jellyfin', 'jf-key', 'user-1');
await client.getAllMovies();
const [url] = fetchMock.mock.calls[0];
expect(url).toContain('Limit=10000');
});
});

View File

@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RadarrClient } from '../../../../src/app/clients/radarr/RadarrClient';
describe('RadarrClient', () => {
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('lookup resolves TMDb metadata and fills in client-configured fields', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({ title: 'Dune', tmdbId: 438631 }),
});
const client = new RadarrClient('http://radarr', 'radarr-key', 4, '/movies');
const movie = await client.lookup(438631);
expect(movie).toEqual({
title: 'Dune',
tmdbId: 438631,
qualityProfileId: 4,
rootFolderPath: '/movies',
monitored: true,
addOptions: { searchForMovie: true },
});
expect(fetchMock.mock.calls[0][1].headers['X-Api-Key']).toBe('radarr-key');
});
it('addMovie treats HTTP 400 (already exists) as success', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 400, text: async () => '' });
const client = new RadarrClient('http://radarr', 'radarr-key', 4, '/movies');
await expect(
client.addMovie({
title: 'Dune',
tmdbId: 438631,
qualityProfileId: 4,
rootFolderPath: '/movies',
monitored: true,
addOptions: { searchForMovie: true },
})
).resolves.toBeUndefined();
});
it('getExisting returns the full Radarr library', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify([{ title: 'Dune', tmdbId: 438631 }]),
});
const client = new RadarrClient('http://radarr', 'radarr-key', 4, '/movies');
const existing = await client.getExisting();
expect(existing[0].tmdbId).toBe(438631);
});
});

View File

@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TmdbClient } from '../../../../src/app/clients/tmdb/TmdbClient';
describe('TmdbClient', () => {
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('searchMovie returns the first result mapped to a TMDbMovie', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
text: async () =>
JSON.stringify({ results: [{ id: 438631, title: 'Dune', release_date: '2021-09-15' }] }),
});
const client = new TmdbClient('tmdb-key', 'https://api.themoviedb.org');
const result = await client.searchMovie('Dune', 2021);
expect(result).toEqual({ id: 438631, title: 'Dune', year: 2021 });
const [url] = fetchMock.mock.calls[0];
expect(url).toContain('/3/search/movie');
expect(url).toContain('query=Dune');
expect(url).toContain('year=2021');
});
it('searchMovie returns undefined when there are no results', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ results: [] }) });
const client = new TmdbClient('tmdb-key', 'https://api.themoviedb.org');
const result = await client.searchMovie('Nonexistent');
expect(result).toBeUndefined();
});
it('getSimilar and getRecommendations hit the right paths', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ results: [] }) });
const client = new TmdbClient('tmdb-key', 'https://api.themoviedb.org');
await client.getSimilar(438631);
await client.getRecommendations(438631);
expect(fetchMock.mock.calls[0][0]).toContain('/3/movie/438631/similar');
expect(fetchMock.mock.calls[1][0]).toContain('/3/movie/438631/recommendations');
});
});

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { loadAppConfig } from '../../../src/app/config/AppConfig';
const REQUIRED_ENV = {
JELLYFIN_URL: 'http://jellyfin',
JELLYFIN_API_KEY: 'jf-key',
JELLYFIN_USER_ID: 'user-1',
TMDB_API_KEY: 'tmdb-key',
LANGFUSE_PUBLIC_KEY: 'lf-pub',
LANGFUSE_SECRET_KEY: 'lf-secret',
LANGFUSE_BASE_URL: 'http://langfuse',
};
describe('loadAppConfig', () => {
it('applies defaults for optional settings', () => {
const config = loadAppConfig(REQUIRED_ENV);
expect(config.LLM_ENABLED).toBe(false);
expect(config.RADARR_ENABLED).toBe(false);
expect(config.API_PORT).toBe(8080);
expect(config.RECOMMEND_COUNT).toBe(10);
expect(config.MIN_RATED_MOVIES).toBe(5);
expect(config.LANGFUSE_PROMPT_CACHE_ENABLED).toBe(true);
});
it('parses boolean and numeric overrides from env strings', () => {
const config = loadAppConfig({
...REQUIRED_ENV,
LLM_ENABLED: 'true',
RADARR_ENABLED: 'true',
API_PORT: '9090',
RECOMMEND_COUNT: '20',
LANGFUSE_PROMPT_CACHE_ENABLED: 'false',
});
expect(config.LLM_ENABLED).toBe(true);
expect(config.RADARR_ENABLED).toBe(true);
expect(config.API_PORT).toBe(9090);
expect(config.RECOMMEND_COUNT).toBe(20);
expect(config.LANGFUSE_PROMPT_CACHE_ENABLED).toBe(false);
});
it('throws when a required var is missing', () => {
const { JELLYFIN_URL, ...rest } = REQUIRED_ENV;
expect(() => loadAppConfig(rest)).toThrow(/Invalid environment configuration/);
});
});

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import Fastify from 'fastify';
import { HealthController } from '../../../src/app/controllers/HealthController';
import type { AppConfig } from '../../../src/app/config/AppConfig';
describe('HealthController', () => {
it('reports status ok with llm/radarr flags from config', async () => {
const app = Fastify();
new HealthController({ LLM_ENABLED: true, RADARR_ENABLED: false } as AppConfig).register(app);
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: 'ok', llm: true, radarr: false });
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest';
import Fastify from 'fastify';
import { RecommendationsController } from '../../../src/app/controllers/RecommendationsController';
import type { AppConfig } from '../../../src/app/config/AppConfig';
import type { RecommendationPipeline } from '../../../src/app/pipelines/RecommendationPipeline';
function makeApp(config: Partial<AppConfig>, pipeline: Partial<RecommendationPipeline>) {
const app = Fastify();
new RecommendationsController(config as AppConfig, pipeline as RecommendationPipeline).register(app);
return app;
}
describe('RecommendationsController', () => {
it('GET /recommendations runs the pipeline with the configured count and LLM flag', async () => {
const runAsync = vi.fn().mockResolvedValue([{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }]);
const app = makeApp({ RECOMMEND_COUNT: 10, LLM_ENABLED: true }, { runAsync } as unknown as RecommendationPipeline);
const res = await app.inject({ method: 'GET', url: '/recommendations' });
expect(res.statusCode).toBe(200);
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
});
it('GET /recommendations?count= and force_llm/force_tmdb override config', async () => {
const runAsync = vi.fn().mockResolvedValue([]);
const app = makeApp({ RECOMMEND_COUNT: 10, LLM_ENABLED: false }, { runAsync } as unknown as RecommendationPipeline);
await app.inject({ method: 'GET', url: '/recommendations?count=3&force_llm=true' });
expect(runAsync).toHaveBeenCalledWith({ count: 3, useLlm: true });
});
it('POST /recommendations/trigger auto-requests via Radarr when enabled', async () => {
const recs = [{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }];
const runAsync = vi.fn().mockResolvedValue(recs);
const autoRequest = vi.fn().mockResolvedValue(undefined);
const app = makeApp(
{ RECOMMEND_COUNT: 10, LLM_ENABLED: true, RADARR_ENABLED: true },
{ runAsync, autoRequest } as unknown as RecommendationPipeline
);
const res = await app.inject({ method: 'POST', url: '/recommendations/trigger' });
expect(res.statusCode).toBe(200);
expect(autoRequest).toHaveBeenCalledWith(recs);
});
it('POST /recommendations/search requires LLM to be enabled and a query body', async () => {
const app = makeApp({ LLM_ENABLED: false }, {} as RecommendationPipeline);
const res = await app.inject({ method: 'POST', url: '/recommendations/search', payload: { query: 'x' } });
expect(res.statusCode).toBe(400);
});
it('POST /recommendations/search runs pipeline.searchAsync and returns results', async () => {
const searchAsync = vi.fn().mockResolvedValue([{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }]);
const app = makeApp(
{ LLM_ENABLED: true, RECOMMEND_COUNT: 10 },
{ searchAsync } as unknown as RecommendationPipeline
);
const res = await app.inject({
method: 'POST',
url: '/recommendations/search',
payload: { query: 'sci-fi like Blade Runner' },
});
expect(res.statusCode).toBe(200);
expect(searchAsync).toHaveBeenCalledWith('sci-fi like Blade Runner', 10);
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import Fastify from 'fastify';
import { RequestController } from '../../../src/app/controllers/RequestController';
import type { AppConfig } from '../../../src/app/config/AppConfig';
import type { RadarrClient } from '../../../src/app/clients/radarr/RadarrClient';
describe('RequestController', () => {
it('returns 400 when Radarr is not enabled', async () => {
const app = Fastify();
new RequestController({ RADARR_ENABLED: false } as AppConfig, undefined).register(app);
const res = await app.inject({ method: 'POST', url: '/recommendations/request', payload: { tmdb_id: 1 } });
expect(res.statusCode).toBe(400);
});
it('returns 400 when tmdb_id is missing', async () => {
const app = Fastify();
new RequestController({ RADARR_ENABLED: true } as AppConfig, {} as RadarrClient).register(app);
const res = await app.inject({ method: 'POST', url: '/recommendations/request', payload: {} });
expect(res.statusCode).toBe(400);
});
it('looks up and adds the movie, returning its title', async () => {
const radarr = {
lookup: vi.fn().mockResolvedValue({ title: 'Dune', tmdbId: 1 }),
addMovie: vi.fn().mockResolvedValue(undefined),
} as unknown as RadarrClient;
const app = Fastify();
new RequestController({ RADARR_ENABLED: true } as AppConfig, radarr).register(app);
const res = await app.inject({ method: 'POST', url: '/recommendations/request', payload: { tmdb_id: 1 } });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ requested: true, title: 'Dune' });
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from 'vitest';
import Fastify from 'fastify';
import { WebhookController } from '../../../src/app/controllers/WebhookController';
import type { PromptService } from '../../../src/core/langfuse/PromptService';
describe('WebhookController', () => {
it('invalidates a single prompt when promptName is given', async () => {
const invalidate = vi.fn();
const app = Fastify();
new WebhookController({ invalidate } as unknown as PromptService).register(app);
const res = await app.inject({
method: 'POST',
url: '/webhooks/langfuse/invalidate',
payload: { promptName: 'movie-recommender' },
});
expect(res.statusCode).toBe(200);
expect(invalidate).toHaveBeenCalledWith('movie-recommender');
expect(res.json()).toEqual({ invalidated: true, promptName: 'movie-recommender' });
});
it('invalidates the whole cache when no promptName is given', async () => {
const invalidate = vi.fn();
const app = Fastify();
new WebhookController({ invalidate } as unknown as PromptService).register(app);
const res = await app.inject({ method: 'POST', url: '/webhooks/langfuse/invalidate', payload: {} });
expect(invalidate).toHaveBeenCalledWith(undefined);
expect(res.json()).toEqual({ invalidated: true, promptName: 'all' });
});
});

View File

@@ -0,0 +1,286 @@
import { describe, expect, it, vi } from 'vitest';
import { RecommendationPipeline } from '../../../src/app/pipelines/RecommendationPipeline';
import type { JellyfinClient } from '../../../src/app/clients/jellyfin/JellyfinClient';
import type { TmdbClient } from '../../../src/app/clients/tmdb/TmdbClient';
import type { RadarrClient } from '../../../src/app/clients/radarr/RadarrClient';
import type { MovieRecommenderTool } from '../../../src/app/tools/MovieRecommenderTool';
import type { MovieSearchTool } from '../../../src/app/tools/MovieSearchTool';
import type { Movie, TMDbMovie } from '../../../src/app/models';
import type { Tracer, TraceHandle } from '../../../src/core/llm/Tracer';
import type { Logger } from '../../../src/core/logging/logger';
const movie = (name: string, year: number, tmdbId: string): Movie => ({
name,
year,
imdbId: '',
tmdbId,
genres: [],
communityRating: 0,
isFavorite: false,
playCount: 0,
});
function makeTracer() {
const trace = { id: 'trace-1', startGeneration: vi.fn() } as unknown as TraceHandle;
const tracer = {
startTrace: vi.fn().mockReturnValue(trace),
flush: vi.fn().mockResolvedValue(undefined),
} as unknown as Tracer;
return tracer;
}
describe('RecommendationPipeline.runAsync', () => {
it('uses favorites as the signal when there are enough, resolves suggestions via TMDb, and excludes library/Radarr matches', async () => {
const jellyfin = {
getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]),
getRecentlyWatched: vi.fn(),
getAllMovies: vi.fn().mockResolvedValue([movie('Arrival', 2016, '2')]),
} as unknown as JellyfinClient;
const searchMovie = vi
.fn()
.mockResolvedValueOnce({ id: 3, title: 'Interstellar', year: 2014 } as TMDbMovie);
const tmdb = { searchMovie } as unknown as TmdbClient;
const radarr = {
getExisting: vi.fn().mockResolvedValue([{ tmdbId: 4 }]),
} as unknown as RadarrClient;
const tool = {
runAsync: vi.fn().mockResolvedValue([{ title: 'Interstellar', year: 2014, reason: 'space epic' }]),
} as unknown as MovieRecommenderTool;
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, {
jellyfin,
tmdb,
radarr,
tool,
searchTool,
minRatedMovies: 1,
maxRetry: 2,
});
const result = await pipeline.runAsync({ count: 1, useLlm: true });
expect(jellyfin.getRecentlyWatched).not.toHaveBeenCalled();
expect(result).toEqual([{ title: 'Interstellar', year: 2014, tmdbId: 3, reason: 'space epic' }]);
expect(tool.runAsync).toHaveBeenCalledWith(
expect.objectContaining({ favorites: [movie('Dune', 2021, '1')], count: 1 }),
expect.anything()
);
});
it('falls back to recently-watched as the signal when favorites are below the threshold', async () => {
const jellyfin = {
getFavoriteMovies: vi.fn().mockResolvedValue([]),
getRecentlyWatched: vi.fn().mockResolvedValue([movie('Arrival', 2016, '2')]),
getAllMovies: vi.fn().mockResolvedValue([]),
} as unknown as JellyfinClient;
const tmdb = { searchMovie: vi.fn().mockResolvedValue(undefined) } as unknown as TmdbClient;
const tool = { runAsync: vi.fn().mockResolvedValue([]) } as unknown as MovieRecommenderTool;
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, {
jellyfin,
tmdb,
tool,
searchTool,
minRatedMovies: 5,
maxRetry: 0,
});
await pipeline.runAsync({ count: 1, useLlm: true });
expect(jellyfin.getRecentlyWatched).toHaveBeenCalled();
expect(tool.runAsync).toHaveBeenCalledWith(
expect.objectContaining({ watched: [movie('Arrival', 2016, '2')] }),
expect.anything()
);
});
it('retries up to maxRetry times, excluding already-suggested movies, until count is reached', async () => {
const jellyfin = {
getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]),
getRecentlyWatched: vi.fn(),
getAllMovies: vi.fn().mockResolvedValue([]),
} as unknown as JellyfinClient;
const tmdb = {
searchMovie: vi
.fn()
.mockResolvedValueOnce({ id: 10, title: 'AlreadyInLibrary', year: 2000 } as TMDbMovie)
.mockResolvedValueOnce({ id: 11, title: 'NewPick', year: 2022 } as TMDbMovie),
} as unknown as TmdbClient;
const tool = {
runAsync: vi
.fn()
.mockResolvedValueOnce([{ title: 'AlreadyInLibrary', year: 2000, reason: 'r1' }])
.mockResolvedValueOnce([{ title: 'NewPick', year: 2022, reason: 'r2' }]),
} as unknown as MovieRecommenderTool;
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, {
jellyfin,
tmdb,
tool,
searchTool,
minRatedMovies: 1,
maxRetry: 2,
});
// Simulate id 10 already excluded (already in library) by pre-seeding via getAllMovies
jellyfin.getAllMovies = vi.fn().mockResolvedValue([movie('AlreadyInLibrary', 2000, '10')]);
const result = await pipeline.runAsync({ count: 1, useLlm: true });
expect(tool.runAsync).toHaveBeenCalledTimes(2);
expect(result).toEqual([{ title: 'NewPick', year: 2022, tmdbId: 11, reason: 'r2' }]);
});
it('uses the TMDb similarity/frequency fallback when useLlm is false', async () => {
const jellyfin = {
getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]),
getRecentlyWatched: vi.fn(),
getAllMovies: vi.fn().mockResolvedValue([]),
} as unknown as JellyfinClient;
const tmdb = {
getSimilar: vi.fn().mockResolvedValue([{ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie]),
getRecommendations: vi
.fn()
.mockResolvedValue([{ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie]),
searchMovie: vi.fn().mockResolvedValue({ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie),
} as unknown as TmdbClient;
const tool = { runAsync: vi.fn() } as unknown as MovieRecommenderTool;
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, {
jellyfin,
tmdb,
tool,
searchTool,
minRatedMovies: 1,
maxRetry: 0,
});
const result = await pipeline.runAsync({ count: 1, useLlm: false });
expect(tool.runAsync).not.toHaveBeenCalled();
expect(result).toEqual([{ title: 'Similar A', year: 2010, tmdbId: 20, reason: 'Recommended by TMDb' }]);
});
});
describe('RecommendationPipeline.autoRequest', () => {
it('looks up and adds each recommendation to Radarr when Radarr is configured', async () => {
const radarr = {
lookup: vi.fn().mockResolvedValue({
title: 'Dune',
tmdbId: 1,
qualityProfileId: 1,
rootFolderPath: '/movies',
monitored: true,
addOptions: { searchForMovie: true },
}),
addMovie: vi.fn().mockResolvedValue(undefined),
} as unknown as RadarrClient;
const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, {
jellyfin: {} as JellyfinClient,
tmdb: {} as TmdbClient,
radarr,
tool: {} as MovieRecommenderTool,
searchTool: {} as MovieSearchTool,
minRatedMovies: 1,
maxRetry: 0,
});
await pipeline.autoRequest([{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }]);
expect(radarr.lookup).toHaveBeenCalledWith(1);
expect(radarr.addMovie).toHaveBeenCalled();
});
it('does nothing when Radarr is not configured', async () => {
const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, {
jellyfin: {} as JellyfinClient,
tmdb: {} as TmdbClient,
tool: {} as MovieRecommenderTool,
searchTool: {} as MovieSearchTool,
minRatedMovies: 1,
maxRetry: 0,
});
await expect(pipeline.autoRequest([{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }])).resolves.toBeUndefined();
});
it('logs and continues when one recommendation fails, instead of aborting the rest', async () => {
const radarr = {
lookup: vi
.fn()
.mockRejectedValueOnce(new Error('radarr down'))
.mockResolvedValueOnce({
title: 'Arrival',
tmdbId: 2,
qualityProfileId: 1,
rootFolderPath: '/movies',
monitored: true,
addOptions: { searchForMovie: true },
}),
addMovie: vi.fn().mockResolvedValue(undefined),
} as unknown as RadarrClient;
const logger = { error: vi.fn() } as unknown as Logger;
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
jellyfin: {} as JellyfinClient,
tmdb: {} as TmdbClient,
radarr,
tool: {} as MovieRecommenderTool,
searchTool: {} as MovieSearchTool,
minRatedMovies: 1,
maxRetry: 0,
});
await pipeline.autoRequest([
{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r1' },
{ title: 'Arrival', year: 2016, tmdbId: 2, reason: 'r2' },
]);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ tmdbId: 1, title: 'Dune' }),
'Radarr auto-request failed'
);
expect(radarr.lookup).toHaveBeenCalledTimes(2);
expect(radarr.addMovie).toHaveBeenCalledTimes(1);
});
});
describe('RecommendationPipeline.searchAsync', () => {
it('runs the search tool, resolves via TMDb, and excludes library matches', async () => {
const jellyfin = {
getAllMovies: vi.fn().mockResolvedValue([movie('AlreadyHave', 1999, '5')]),
} as unknown as JellyfinClient;
const tmdb = {
searchMovie: vi.fn().mockResolvedValue({ id: 6, title: 'New Find', year: 2020 } as TMDbMovie),
} as unknown as TmdbClient;
const searchTool = {
runAsync: vi.fn().mockResolvedValue([{ title: 'New Find', year: 2020, reason: 'matches query' }]),
} as unknown as MovieSearchTool;
const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, {
jellyfin,
tmdb,
tool: {} as MovieRecommenderTool,
searchTool,
minRatedMovies: 1,
maxRetry: 0,
});
const result = await pipeline.searchAsync('sci-fi like Dune', 5);
expect(result).toEqual([{ title: 'New Find', year: 2020, tmdbId: 6, reason: 'matches query' }]);
expect(searchTool.runAsync).toHaveBeenCalledWith({ query: 'sci-fi like Dune', count: 5 }, expect.anything());
});
});

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { MovieRecommenderTool } from '../../../src/app/tools/MovieRecommenderTool';
import type { RunContext } from '../../../src/core/llm/RunContext';
import type { Movie } from '../../../src/app/models';
function makeTool() {
return new MovieRecommenderTool(
{} as unknown as ConstructorParameters<typeof MovieRecommenderTool>[0],
{} as unknown as ConstructorParameters<typeof MovieRecommenderTool>[1]
) as unknown as {
buildVariables(input: unknown, ctx: RunContext): Record<string, unknown>;
parseOutput(raw: string, ctx: RunContext): unknown;
};
}
const movie = (name: string, year: number): Movie => ({
name,
year,
imdbId: '',
tmdbId: '',
genres: [],
communityRating: 0,
isFavorite: false,
playCount: 0,
});
describe('MovieRecommenderTool.buildVariables', () => {
it('formats favorites/watched/library/previouslySuggested as readable lists', () => {
const tool = makeTool();
const variables = tool.buildVariables(
{
favorites: [movie('Dune', 2021)],
watched: [],
library: [movie('Arrival', 2016)],
previouslySuggested: [],
count: 5,
},
{} as RunContext
);
expect(variables).toEqual({
count: 5,
favorites: '- Dune (2021)',
watched: '(none)',
library: '- Arrival (2016)',
previouslySuggested: '(none)',
});
});
});
describe('MovieRecommenderTool.parseOutput', () => {
it('validates suggestions against the movie suggestion schema', () => {
const tool = makeTool();
const result = tool.parseOutput(
'[{"title":"Dune","year":2021,"reason":"sci-fi epic"}]',
{} as RunContext
);
expect(result).toEqual([{ title: 'Dune', year: 2021, reason: 'sci-fi epic' }]);
});
});

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { MovieSearchTool } from '../../../src/app/tools/MovieSearchTool';
import type { RunContext } from '../../../src/core/llm/RunContext';
describe('MovieSearchTool.buildVariables', () => {
it('passes the query and requested count through as prompt variables', () => {
const tool = new MovieSearchTool(
{} as unknown as ConstructorParameters<typeof MovieSearchTool>[0],
{} as unknown as ConstructorParameters<typeof MovieSearchTool>[1]
) as unknown as { buildVariables(input: unknown, ctx: RunContext): Record<string, unknown> };
const variables = tool.buildVariables({ query: 'sci-fi like Blade Runner', count: 5 }, {} as RunContext);
expect(variables).toEqual({ query: 'sci-fi like Blade Runner', count: 5 });
});
});

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { loadEnv } from '../../../src/core/config/env';
describe('loadEnv', () => {
const schema = z.object({
FOO: z.string(),
COUNT: z.coerce.number().default(5),
});
it('parses matching env vars into a typed object', () => {
const result = loadEnv(schema, { FOO: 'bar', COUNT: '10' });
expect(result).toEqual({ FOO: 'bar', COUNT: 10 });
});
it('throws a descriptive error when required vars are missing', () => {
expect(() => loadEnv(schema, {})).toThrow(/Invalid environment configuration/);
});
});

View File

@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClient, ApiError } from '../../../src/core/http/ApiClient';
import { Endpoint, HttpMethod } from '../../../src/core/http/Endpoint';
interface GetParams {
id: number;
}
class GetThingEndpoint extends Endpoint<{ name: string }, GetParams> {
readonly method: HttpMethod = 'GET';
path(params: GetParams): string {
return `/things/${params.id}`;
}
query(): Record<string, string> {
return { verbose: 'true' };
}
parseResponse(json: unknown): { name: string } {
return json as { name: string };
}
}
class PostThingEndpoint extends Endpoint<void, { name: string }> {
readonly method: HttpMethod = 'POST';
readonly acceptableStatuses = [400];
path(): string {
return '/things';
}
body(params: { name: string }): unknown {
return params;
}
parseResponse(): void {
return undefined;
}
}
class TestClient extends ApiClient {
constructor(baseUrl: string) {
super(baseUrl);
}
protected buildHeaders(): Record<string, string> {
return { ...super.buildHeaders(), 'X-Test': '1' };
}
getThing(id: number) {
return this.request(new GetThingEndpoint(), { id });
}
postThing(name: string) {
return this.request(new PostThingEndpoint(), { name });
}
}
describe('ApiClient', () => {
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('builds the URL with query params and headers, and parses the response', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({ name: 'widget' }),
});
const client = new TestClient('https://api.example.com');
const result = await client.getThing(42);
expect(result).toEqual({ name: 'widget' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.example.com/things/42?verbose=true');
expect(init.method).toBe('GET');
expect(init.headers['X-Test']).toBe('1');
});
it('sends a JSON body and Content-Type header for POST', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
const client = new TestClient('https://api.example.com');
await client.postThing('widget');
const [, init] = fetchMock.mock.calls[0];
expect(init.body).toBe(JSON.stringify({ name: 'widget' }));
expect(init.headers['Content-Type']).toBe('application/json');
});
it('treats an endpoint-declared acceptable status as success', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 400, text: async () => '' });
const client = new TestClient('https://api.example.com');
await expect(client.postThing('widget')).resolves.toBeUndefined();
});
it('throws ApiError for a non-ok, non-acceptable status', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' });
const client = new TestClient('https://api.example.com');
await expect(client.getThing(1)).rejects.toBeInstanceOf(ApiError);
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest';
const compile = vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]);
const getPrompt = vi.fn().mockResolvedValue({ name: 'greet', version: 2, config: { model: 'llama3' }, compile });
vi.mock('langfuse', () => ({
Langfuse: vi.fn().mockImplementation(() => ({ getPrompt })),
}));
import { LangfusePromptGateway } from '../../../src/core/langfuse/LangfusePromptGateway';
describe('LangfusePromptGateway', () => {
it('fetches a chat prompt from Langfuse bypassing the SDK cache, and adapts it to RawPrompt', async () => {
const gateway = new LangfusePromptGateway({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
const raw = await gateway.fetch('greet');
expect(getPrompt).toHaveBeenCalledWith('greet', undefined, { type: 'chat', cacheTtlSeconds: 0 });
expect(raw.name).toBe('greet');
expect(raw.version).toBe(2);
expect(raw.config).toEqual({ model: 'llama3' });
expect(raw.compile({ who: 'alice' })).toEqual([{ role: 'user', content: 'hi' }]);
});
});

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
const generationEnd = vi.fn();
const generation = vi.fn().mockReturnValue({ end: generationEnd });
const trace = vi.fn().mockReturnValue({ id: 'lf-trace-1', generation });
const flushAsync = vi.fn().mockResolvedValue(undefined);
vi.mock('langfuse', () => ({
Langfuse: vi.fn().mockImplementation(() => ({ trace, flushAsync })),
}));
import { LangfuseTracer } from '../../../src/core/langfuse/LangfuseTracer';
describe('LangfuseTracer', () => {
it('starts a Langfuse trace and exposes its id', () => {
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
const handle = tracer.startTrace('my-pipeline', { foo: 'bar' });
expect(trace).toHaveBeenCalledWith({ name: 'my-pipeline', metadata: { foo: 'bar' } });
expect(handle.id).toBe('lf-trace-1');
});
it('startGeneration creates a Langfuse generation and end() forwards output/usage', () => {
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
const handle = tracer.startTrace('my-pipeline');
const gen = handle.startGeneration({ name: 'step-1', model: 'llama3', input: ['hi'] });
gen.end({ output: 'done', usage: { totalTokens: 5 } });
expect(generation).toHaveBeenCalledWith(
expect.objectContaining({ name: 'step-1', model: 'llama3', input: ['hi'] })
);
expect(generationEnd).toHaveBeenCalledWith(
expect.objectContaining({ output: 'done', usage: { totalTokens: 5 } })
);
});
it('flush() calls flushAsync on the underlying client', async () => {
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
await tracer.flush();
expect(flushAsync).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import { PromptService } from '../../../src/core/langfuse/PromptService';
import type { PromptFetcher, RawPrompt } from '../../../src/core/langfuse/PromptGateway';
function makeRawPrompt(name: string, version = 1): RawPrompt {
return {
name,
version,
config: { model: 'gpt-4o-mini' },
compile: (variables) => [{ role: 'user', content: `hello ${String(variables.who)}` }],
};
}
describe('PromptService', () => {
it('fetches once and reuses the cache on subsequent calls when caching is enabled', async () => {
const fetch = vi.fn().mockResolvedValue(makeRawPrompt('greet'));
const fetcher: PromptFetcher = { fetch };
const service = new PromptService(fetcher, true);
await service.getCompiled('greet', { who: 'a' });
const compiled = await service.getCompiled('greet', { who: 'b' });
expect(fetch).toHaveBeenCalledTimes(1);
expect(compiled.messages).toEqual([{ role: 'user', content: 'hello b' }]);
expect(compiled.config).toEqual({ model: 'gpt-4o-mini' });
});
it('always refetches when caching is disabled', async () => {
const fetch = vi.fn().mockResolvedValue(makeRawPrompt('greet'));
const fetcher: PromptFetcher = { fetch };
const service = new PromptService(fetcher, false);
await service.getCompiled('greet', { who: 'a' });
await service.getCompiled('greet', { who: 'b' });
expect(fetch).toHaveBeenCalledTimes(2);
});
it('invalidate(name) forces a refetch for that name only', async () => {
const fetch = vi.fn().mockResolvedValue(makeRawPrompt('greet'));
const fetcher: PromptFetcher = { fetch };
const service = new PromptService(fetcher, true);
await service.getCompiled('greet', {});
service.invalidate('greet');
await service.getCompiled('greet', {});
expect(fetch).toHaveBeenCalledTimes(2);
});
it('invalidate() with no name clears the whole cache', async () => {
const fetch = vi.fn().mockImplementation((name: string) => Promise.resolve(makeRawPrompt(name)));
const fetcher: PromptFetcher = { fetch };
const service = new PromptService(fetcher, true);
await service.getCompiled('a', {});
await service.getCompiled('b', {});
service.invalidate();
await service.getCompiled('a', {});
await service.getCompiled('b', {});
expect(fetch).toHaveBeenCalledTimes(4);
});
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
const createMock = vi.fn();
vi.mock('openai', () => ({
default: vi.fn().mockImplementation(() => ({
chat: { completions: { create: createMock } },
})),
}));
import { LlmClient } from '../../../src/core/llm/LlmClient';
describe('LlmClient', () => {
it('sends messages/options to the OpenAI-compatible endpoint and returns content + usage', async () => {
createMock.mockResolvedValue({
choices: [{ message: { content: 'hello' } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
});
const client = new LlmClient({ baseUrl: 'http://localhost:11434/v1', apiKey: 'key' });
const result = await client.chat([{ role: 'user', content: 'hi' }], { model: 'llama3', temperature: 0.7 });
expect(result).toEqual({
content: 'hello',
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
});
expect(createMock).toHaveBeenCalledWith({
model: 'llama3',
temperature: 0.7,
max_tokens: undefined,
messages: [{ role: 'user', content: 'hi' }],
});
});
it('throws when the response has no choices', async () => {
createMock.mockResolvedValue({ choices: [] });
const client = new LlmClient({ baseUrl: 'http://localhost:11434/v1' });
await expect(client.chat([{ role: 'user', content: 'hi' }], { model: 'llama3' })).rejects.toThrow(
/no choices/
);
});
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import { LlmPipeline } from '../../../src/core/llm/LlmPipeline';
import type { RunContext } from '../../../src/core/llm/RunContext';
import type { Tracer, TraceHandle } from '../../../src/core/llm/Tracer';
import type { LlmTool } from '../../../src/core/llm/LlmTool';
import type { Logger } from '../../../src/core/logging/logger';
class SumPipeline extends LlmPipeline<number[], number> {
protected readonly name = 'sum-pipeline';
protected async runInternal(input: number[], ctx: RunContext): Promise<number> {
expect(ctx.trace.id).toBe('trace-1');
return input.reduce((a, b) => a + b, 0);
}
}
class ThrowingPipeline extends LlmPipeline<void, void> {
protected readonly name = 'throwing-pipeline';
protected async runInternal(): Promise<void> {
throw new Error('pipeline failed');
}
}
function makeTracer(trace: TraceHandle) {
const flush = vi.fn().mockResolvedValue(undefined);
const startTrace = vi.fn().mockReturnValue(trace);
const tracer = { startTrace, flush } as unknown as Tracer;
return { tracer, startTrace, flush };
}
describe('LlmPipeline', () => {
it('opens one trace, runs runInternal with it, and flushes afterward', async () => {
const trace = { id: 'trace-1', startGeneration: vi.fn() } as unknown as TraceHandle;
const { tracer, startTrace, flush } = makeTracer(trace);
const logger = {} as unknown as Logger;
const pipeline = new SumPipeline(tracer, logger);
const result = await pipeline.runAsync([1, 2, 3]);
expect(result).toBe(6);
expect(startTrace).toHaveBeenCalledWith('sum-pipeline');
expect(flush).toHaveBeenCalledTimes(1);
});
it('still flushes the trace when runInternal throws', async () => {
const trace = { id: 'trace-2', startGeneration: vi.fn() } as unknown as TraceHandle;
const { tracer, flush } = makeTracer(trace);
const logger = {} as unknown as Logger;
const pipeline = new ThrowingPipeline(tracer, logger);
await expect(pipeline.runAsync()).rejects.toThrow('pipeline failed');
expect(flush).toHaveBeenCalledTimes(1);
});
it('runTool delegates to the given tool.runAsync with the same context', async () => {
const trace = { id: 'trace-3', startGeneration: vi.fn() } as unknown as TraceHandle;
const { tracer } = makeTracer(trace);
const logger = {} as unknown as Logger;
class ToolRunnerPipeline extends LlmPipeline<void, string> {
protected readonly name = 'tool-runner';
protected async runInternal(_input: void, ctx: RunContext): Promise<string> {
const tool = { runAsync: vi.fn().mockResolvedValue('tool-output') } as unknown as LlmTool<
Record<string, never>,
string
>;
return this.runTool(tool, {}, ctx);
}
}
const pipeline = new ToolRunnerPipeline(tracer, logger);
const result = await pipeline.runAsync();
expect(result).toBe('tool-output');
});
});

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';
import { LlmTool } from '../../../src/core/llm/LlmTool';
import type { RunContext } from '../../../src/core/llm/RunContext';
import type { PromptService } from '../../../src/core/langfuse/PromptService';
import type { LlmClient } from '../../../src/core/llm/LlmClient';
interface TestInput {
who: string;
}
class GreetTool extends LlmTool<TestInput, string> {
protected readonly promptName = 'greet';
protected buildVariables(input: TestInput): Record<string, unknown> {
return { who: input.who };
}
protected parseOutput(raw: string): string {
return raw.toUpperCase();
}
}
function makeCtx() {
const generationEnd = vi.fn();
const startGeneration = vi.fn().mockReturnValue({ end: generationEnd });
const trace = { id: 'trace-1', startGeneration };
const logger = { info: vi.fn(), error: vi.fn() } as unknown as RunContext['logger'];
return { ctx: { trace, logger } as RunContext, startGeneration, generationEnd };
}
describe('LlmTool.runAsync', () => {
it('fetches the compiled prompt, calls the LLM, and parses the output', async () => {
const getCompiled = vi.fn().mockResolvedValue({
name: 'greet',
version: 1,
config: { model: 'llama3', temperature: 0.5 },
messages: [{ role: 'user', content: 'hi alice' }],
});
const promptService = { getCompiled } as unknown as PromptService;
const chat = vi.fn().mockResolvedValue({ content: 'hello alice', usage: { totalTokens: 3 } });
const llmClient = { chat } as unknown as LlmClient;
const { ctx, startGeneration, generationEnd } = makeCtx();
const tool = new GreetTool(promptService, llmClient);
const result = await tool.runAsync({ who: 'alice' }, ctx);
expect(result).toBe('HELLO ALICE');
expect(getCompiled).toHaveBeenCalledWith('greet', { who: 'alice' });
expect(chat).toHaveBeenCalledWith(
[{ role: 'user', content: 'hi alice' }],
{ model: 'llama3', temperature: 0.5, maxTokens: undefined }
);
expect(startGeneration).toHaveBeenCalledWith(
expect.objectContaining({ name: 'greet', model: 'llama3' })
);
expect(generationEnd).toHaveBeenCalledWith(
expect.objectContaining({ output: 'hello alice' })
);
});
it('ends the generation with error metadata and rethrows when the LLM call fails', async () => {
const promptService = {
getCompiled: vi.fn().mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [] }),
} as unknown as PromptService;
const llmClient = { chat: vi.fn().mockRejectedValue(new Error('boom')) } as unknown as LlmClient;
const { ctx, generationEnd } = makeCtx();
const tool = new GreetTool(promptService, llmClient);
await expect(tool.runAsync({ who: 'alice' }, ctx)).rejects.toThrow('boom');
expect(generationEnd).toHaveBeenCalledWith(
expect.objectContaining({ output: null })
);
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { extractJson, LlmOutputParseError, SchemaLlmTool } from '../../../src/core/llm/SchemaLlmTool';
import type { RunContext } from '../../../src/core/llm/RunContext';
describe('extractJson', () => {
it('parses a bare JSON array', () => {
expect(extractJson('[{"a":1}]')).toEqual([{ a: 1 }]);
});
it('strips a ```json fenced block with surrounding prose', () => {
const content = 'Sure, here you go:\n```json\n[{"a":1}]\n```\nHope that helps!';
expect(extractJson(content)).toEqual([{ a: 1 }]);
});
it('strips a plain ``` fenced block', () => {
const content = '```\n{"a":1}\n```';
expect(extractJson(content)).toEqual({ a: 1 });
});
it('throws LlmOutputParseError when no JSON is present', () => {
expect(() => extractJson('no json here')).toThrow(LlmOutputParseError);
});
});
const outputSchema = z.array(z.object({ title: z.string(), year: z.number() }));
class ListTool extends SchemaLlmTool<Record<string, never>, z.infer<typeof outputSchema>> {
protected readonly promptName = 'list';
protected readonly outputSchema = outputSchema;
protected buildVariables(): Record<string, unknown> {
return {};
}
}
function makeTool() {
return new ListTool(
{} as unknown as ConstructorParameters<typeof ListTool>[0],
{} as unknown as ConstructorParameters<typeof ListTool>[1]
);
}
describe('SchemaLlmTool.parseOutput (via subclass)', () => {
it('validates parsed JSON against the schema', () => {
const tool = makeTool() as unknown as { parseOutput(raw: string, ctx: RunContext): unknown };
const result = tool.parseOutput('[{"title":"Dune","year":2021}]', {} as RunContext);
expect(result).toEqual([{ title: 'Dune', year: 2021 }]);
});
it('throws LlmOutputParseError when schema validation fails', () => {
const tool = makeTool() as unknown as { parseOutput(raw: string, ctx: RunContext): unknown };
expect(() => tool.parseOutput('[{"title":"Dune"}]', {} as RunContext)).toThrow(LlmOutputParseError);
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { TextLlmTool } from '../../../src/core/llm/TextLlmTool';
import type { RunContext } from '../../../src/core/llm/RunContext';
class EchoTool extends TextLlmTool<Record<string, never>> {
protected readonly promptName = 'echo';
protected buildVariables(): Record<string, unknown> {
return {};
}
}
describe('TextLlmTool.parseOutput (via subclass)', () => {
it('trims the raw LLM text and returns it as-is', () => {
const tool = new EchoTool(
{} as unknown as ConstructorParameters<typeof EchoTool>[0],
{} as unknown as ConstructorParameters<typeof EchoTool>[1]
) as unknown as { parseOutput(raw: string, ctx: RunContext): string };
expect(tool.parseOutput(' hello world \n', {} as RunContext)).toBe('hello world');
});
});

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { createLogger } from '../../../src/core/logging/logger';
describe('createLogger', () => {
it('creates a pino logger with the given name bound', () => {
const logger = createLogger('test-logger');
expect(logger.bindings().name).toBe('test-logger');
expect(typeof logger.info).toBe('function');
expect(typeof logger.error).toBe('function');
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import Fastify from 'fastify';
import { registerAuthHook } from '../../../src/core/server/authHook';
function buildApp(secretKey: string) {
const app = Fastify();
registerAuthHook(app, secretKey);
app.get('/health', async () => ({ status: 'ok' }));
app.get('/recommendations', async () => ({ items: [] }));
return app;
}
describe('registerAuthHook', () => {
it('allows all requests through when secretKey is empty', async () => {
const app = buildApp('');
const res = await app.inject({ method: 'GET', url: '/recommendations' });
expect(res.statusCode).toBe(200);
});
it('always exempts /health even when a secret is configured', async () => {
const app = buildApp('s3cret');
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
});
it('rejects requests without a matching bearer token', async () => {
const app = buildApp('s3cret');
const res = await app.inject({ method: 'GET', url: '/recommendations' });
expect(res.statusCode).toBe(401);
});
it('allows requests with the correct bearer token', async () => {
const app = buildApp('s3cret');
const res = await app.inject({
method: 'GET',
url: '/recommendations',
headers: { authorization: 'Bearer s3cret' },
});
expect(res.statusCode).toBe(200);
});
});

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"]
}

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.ts'],
},
});