mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-11 07:41:08 +00:00
Compare commits
34 Commits
20ebe5a49f
...
feature/ty
| Author | SHA1 | Date | |
|---|---|---|---|
| ae8bfaa2eb | |||
| f26a5d72b2 | |||
| 31de7f15db | |||
| e712470013 | |||
| 3c5f723d22 | |||
| 62848f6ecd | |||
| 5edeb0ebb9 | |||
| 5140bc5a50 | |||
| 8e908408cd | |||
| 4e483557d7 | |||
| 9683d65a1f | |||
| ef037d25fe | |||
| 69db1d5558 | |||
| bb479f7749 | |||
| cf3c12774f | |||
| 299375bcd3 | |||
| 3f99bce3c6 | |||
| 8d468903a4 | |||
| ee4aba83e8 | |||
| 3ca58194e6 | |||
| 81e9a20b14 | |||
| 580357a130 | |||
| ca08ed9294 | |||
| a9a8fa7c3f | |||
| abfbc1e2da | |||
| 16e61a77e6 | |||
| 1167d66585 | |||
| 58a2b00e3d | |||
| 8e37ff2ed6 | |||
| 5dd8902472 | |||
| abb228052c | |||
| c5135f3190 | |||
| b6ad5a5121 | |||
| 846083e230 |
30
.env
30
.env
@@ -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
38
.env.example
Normal 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
15
.eslintrc.cjs
Normal 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
42
.gitignore
vendored
@@ -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
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
17
Dockerfile.node
Normal file
17
Dockerfile.node
Normal 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"]
|
||||
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# sugarr
|
||||
|
||||
Movie-recommendation companion for Jellyfin: pulls your favorite/recently-watched
|
||||
movies, gets suggestions from an LLM (via Langfuse-managed prompts) or a TMDb
|
||||
similarity/frequency fallback, resolves them against TMDb, excludes anything
|
||||
already in your library or Radarr, and can auto-request the rest via Radarr.
|
||||
Runs on a cron schedule and/or as an HTTP API.
|
||||
|
||||
This repo currently has two implementations:
|
||||
|
||||
- **`cmd/`, `internal/`** — the original Go implementation.
|
||||
- **`src/`** — a TypeScript/OOP rewrite (Node + Fastify + Langfuse), the one
|
||||
documented below. See `docs/superpowers/specs/2026-08-12-typescript-oop-rewrite-design.md`
|
||||
and `docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md` for the
|
||||
design and implementation plan.
|
||||
|
||||
## Running the TypeScript app
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env # fill in the values below
|
||||
pnpm dev # or: pnpm build && pnpm start
|
||||
```
|
||||
|
||||
### Required environment variables
|
||||
|
||||
See `.env.example` for the full list with defaults. At minimum you need:
|
||||
|
||||
- `JELLYFIN_URL`, `JELLYFIN_API_KEY`, `JELLYFIN_USER_ID`, `TMDB_API_KEY`
|
||||
- `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_BASE_URL` — the app
|
||||
fetches its LLM prompts from Langfuse at runtime (see below), so these are
|
||||
required even if `LLM_ENABLED=false` (only the recommend/search endpoints
|
||||
actually call Langfuse; if you never enable the LLM path you can point
|
||||
these at any reachable Langfuse instance).
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.node.yml up -d --build
|
||||
```
|
||||
|
||||
Uses `Dockerfile.node` / `docker-compose.node.yml`, kept separate from the Go
|
||||
app's `Dockerfile` / `docker-compose.yml`.
|
||||
|
||||
## Seeding Langfuse prompts
|
||||
|
||||
The app fetches two chat prompts from Langfuse by name — `movie-recommender`
|
||||
and `movie-search` — compiles them with per-request variables, and reads
|
||||
model/temperature/etc. from each prompt version's `config` field. Ready-to-import
|
||||
definitions are in `langfuse/`:
|
||||
|
||||
- `langfuse/movie-recommender.prompt.json`
|
||||
- `langfuse/movie-search.prompt.json`
|
||||
|
||||
Create each one in your Langfuse project (UI: **Prompts → New prompt**, type
|
||||
**Chat**; or via the Langfuse API/SDK) using the `prompt` array as the chat
|
||||
messages and the `config` object as the prompt's config. Variables use
|
||||
`{{name}}` interpolation:
|
||||
|
||||
- `movie-recommender`: `count`, `favorites`, `watched`, `library`, `previouslySuggested`
|
||||
- `movie-search`: `query`, `count`
|
||||
|
||||
## Prompt caching and invalidation
|
||||
|
||||
Compiled prompts are cached in memory per process (`LANGFUSE_PROMPT_CACHE_ENABLED`,
|
||||
default `true`; set to `false` to always fetch fresh from Langfuse — useful
|
||||
while iterating on a prompt). To invalidate the cache after updating a prompt
|
||||
in Langfuse without restarting the app, call:
|
||||
|
||||
```
|
||||
POST /webhooks/langfuse/invalidate
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <SECRET_KEY> # if SECRET_KEY is set
|
||||
|
||||
{"promptName": "movie-recommender"} # or {} / omit body to clear the whole cache
|
||||
```
|
||||
|
||||
This isn't wired to a Langfuse-native push webhook (self-hosted Langfuse has
|
||||
no built-in "prompt updated" event) — call it manually, from CI after a prompt
|
||||
change, or from Langfuse Cloud's webhook feature if you have one configured.
|
||||
|
||||
## HTTP API
|
||||
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/health` | exempt from auth |
|
||||
| GET | `/recommendations` | `?count=`, `?force_llm=true`, `?force_tmdb=true` |
|
||||
| POST | `/recommendations/search` | body `{"query": "..."}`, requires `LLM_ENABLED=true` |
|
||||
| POST | `/recommendations/trigger` | runs the pipeline + auto-requests via Radarr if enabled |
|
||||
| POST | `/recommendations/request` | body `{"tmdb_id": 12345}`, requires `RADARR_ENABLED=true` |
|
||||
| POST | `/webhooks/langfuse/invalidate` | body `{"promptName"?: string}` |
|
||||
|
||||
If `SECRET_KEY` is set, all routes except `/health` require
|
||||
`Authorization: Bearer <SECRET_KEY>`.
|
||||
@@ -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
40
docker-compose.node.yml
Normal 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:
|
||||
@@ -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
|
||||
|
||||
3963
docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md
Normal file
3963
docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
20
langfuse/movie-recommender.prompt.json
Normal file
20
langfuse/movie-recommender.prompt.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "movie-recommender",
|
||||
"type": "chat",
|
||||
"prompt": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a movie recommendation engine. Based on the user's favorite and recently watched movies below, suggest exactly {{count}} movies they would enjoy.\n\nIMPORTANT:\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\"}"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "User's favorite movies:\n{{favorites}}\n\nUser's recently watched movies:\n{{watched}}\n\nAlready in library (DO NOT suggest these):\n{{library}}\n\nAlready suggested (DO NOT repeat these):\n{{previouslySuggested}}"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"model": "llama3",
|
||||
"temperature": 0.7,
|
||||
"maxTokens": 1024
|
||||
},
|
||||
"labels": ["production"]
|
||||
}
|
||||
20
langfuse/movie-search.prompt.json
Normal file
20
langfuse/movie-search.prompt.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "movie-search",
|
||||
"type": "chat",
|
||||
"prompt": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a movie recommendation engine. Return ONLY a JSON array of {{count}} movies matching the user's request. No other text.\nEach entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "The user is looking for: {{query}}"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"model": "llama3",
|
||||
"temperature": 0.7,
|
||||
"maxTokens": 1024
|
||||
},
|
||||
"labels": ["production"]
|
||||
}
|
||||
36
package.json
Normal file
36
package.json
Normal 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
3013
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
src/app/clients/jellyfin/JellyfinClient.ts
Normal file
61
src/app/clients/jellyfin/JellyfinClient.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
46
src/app/clients/jellyfin/endpoints/ItemsEndpoint.ts
Normal file
46
src/app/clients/jellyfin/endpoints/ItemsEndpoint.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
45
src/app/clients/radarr/RadarrClient.ts
Normal file
45
src/app/clients/radarr/RadarrClient.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/app/clients/radarr/endpoints/AddMovieEndpoint.ts
Normal file
16
src/app/clients/radarr/endpoints/AddMovieEndpoint.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/app/clients/radarr/endpoints/GetExistingEndpoint.ts
Normal file
12
src/app/clients/radarr/endpoints/GetExistingEndpoint.ts
Normal 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[];
|
||||
}
|
||||
}
|
||||
32
src/app/clients/radarr/endpoints/LookupEndpoint.ts
Normal file
32
src/app/clients/radarr/endpoints/LookupEndpoint.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
32
src/app/clients/tmdb/TmdbClient.ts
Normal file
32
src/app/clients/tmdb/TmdbClient.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
44
src/app/clients/tmdb/endpoints/MovieListEndpoint.ts
Normal file
44
src/app/clients/tmdb/endpoints/MovieListEndpoint.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
41
src/app/clients/tmdb/endpoints/SearchMovieEndpoint.ts
Normal file
41
src/app/clients/tmdb/endpoints/SearchMovieEndpoint.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
53
src/app/config/AppConfig.ts
Normal file
53
src/app/config/AppConfig.ts
Normal 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);
|
||||
}
|
||||
17
src/app/controllers/HealthController.ts
Normal file
17
src/app/controllers/HealthController.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
82
src/app/controllers/RecommendationsController.ts
Normal file
82
src/app/controllers/RecommendationsController.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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 parsedCount = request.query.count ? Number(request.query.count) : NaN;
|
||||
const count = Number.isInteger(parsedCount) && parsedCount > 0 ? parsedCount : 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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/app/controllers/RequestController.ts
Normal file
42
src/app/controllers/RequestController.ts
Normal 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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/app/controllers/WebhookController.ts
Normal file
25
src/app/controllers/WebhookController.ts
Normal 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
26
src/app/cron.ts
Normal 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
86
src/app/index.ts
Normal 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, config.LLM_MODEL);
|
||||
const searchTool = new MovieSearchTool(promptService, llmClient, config.LLM_MODEL);
|
||||
|
||||
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
29
src/app/models.ts
Normal 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;
|
||||
}
|
||||
207
src/app/pipelines/RecommendationPipeline.ts
Normal file
207
src/app/pipelines/RecommendationPipeline.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
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;
|
||||
|
||||
let library: Movie[] = [];
|
||||
try {
|
||||
library = await this.options.jellyfin.getAllMovies();
|
||||
} catch (err) {
|
||||
ctx.logger.warn({ err }, 'Could not fetch library; exclusion may be incomplete');
|
||||
}
|
||||
const exclude = new Set<number>(toTmdbIds(library));
|
||||
|
||||
if (this.options.radarr) {
|
||||
try {
|
||||
const existing = await this.options.radarr.getExisting();
|
||||
for (const m of existing) exclude.add(m.tmdbId);
|
||||
} catch (err) {
|
||||
ctx.logger.warn({ err }, 'Could not fetch Radarr library; exclusion may be incomplete');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
let resolved: TMDbMovie | undefined;
|
||||
try {
|
||||
resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year);
|
||||
} catch (err) {
|
||||
ctx.logger.warn({ err, title: suggestion.title }, 'TMDb search failed, skipping suggestion');
|
||||
continue;
|
||||
}
|
||||
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 {
|
||||
let library: Movie[] = [];
|
||||
try {
|
||||
library = await this.options.jellyfin.getAllMovies();
|
||||
} catch (err) {
|
||||
ctx.logger.warn({ err }, 'Could not fetch library; exclusion may be incomplete');
|
||||
}
|
||||
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;
|
||||
let resolved: TMDbMovie | undefined;
|
||||
try {
|
||||
resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year);
|
||||
} catch (err) {
|
||||
ctx.logger.warn({ err, title: suggestion.title }, 'TMDb search failed, skipping suggestion');
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
let similar: TMDbMovie[] = [];
|
||||
let recommended: TMDbMovie[] = [];
|
||||
try {
|
||||
[similar, recommended] = await Promise.all([
|
||||
this.options.tmdb.getSimilar(tmdbId),
|
||||
this.options.tmdb.getRecommendations(tmdbId),
|
||||
]);
|
||||
} catch (err) {
|
||||
this.logger.warn({ err, tmdbId }, 'TMDb similar/recommendations failed, skipping signal movie');
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
38
src/app/tools/MovieRecommenderTool.ts
Normal file
38
src/app/tools/MovieRecommenderTool.ts
Normal 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');
|
||||
}
|
||||
24
src/app/tools/MovieSearchTool.ts
Normal file
24
src/app/tools/MovieSearchTool.ts
Normal 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
12
src/core/config/env.ts
Normal 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;
|
||||
}
|
||||
48
src/core/http/ApiClient.ts
Normal file
48
src/core/http/ApiClient.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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) {
|
||||
const safeUrl = url.split('?')[0];
|
||||
throw new ApiError(`${endpoint.method} ${safeUrl} 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
18
src/core/http/Endpoint.ts
Normal 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;
|
||||
}
|
||||
28
src/core/langfuse/LangfusePromptGateway.ts
Normal file
28
src/core/langfuse/LangfusePromptGateway.ts
Normal 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;
|
||||
}[],
|
||||
};
|
||||
}
|
||||
}
|
||||
40
src/core/langfuse/LangfuseTracer.ts
Normal file
40
src/core/langfuse/LangfuseTracer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
10
src/core/langfuse/PromptGateway.ts
Normal file
10
src/core/langfuse/PromptGateway.ts
Normal 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>;
|
||||
}
|
||||
46
src/core/langfuse/PromptService.ts
Normal file
46
src/core/langfuse/PromptService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
src/core/langfuse/types.ts
Normal file
6
src/core/langfuse/types.ts
Normal 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
50
src/core/llm/LlmClient.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
32
src/core/llm/LlmPipeline.ts
Normal file
32
src/core/llm/LlmPipeline.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
60
src/core/llm/LlmTool.ts
Normal file
60
src/core/llm/LlmTool.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
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 readonly defaultModel: string = 'gpt-4o-mini'
|
||||
) {}
|
||||
|
||||
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 ?? this.defaultModel);
|
||||
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;
|
||||
}
|
||||
}
|
||||
7
src/core/llm/RunContext.ts
Normal file
7
src/core/llm/RunContext.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Logger } from '../logging/logger';
|
||||
import type { TraceHandle } from './Tracer';
|
||||
|
||||
export interface RunContext {
|
||||
trace: TraceHandle;
|
||||
logger: Logger;
|
||||
}
|
||||
60
src/core/llm/SchemaLlmTool.ts
Normal file
60
src/core/llm/SchemaLlmTool.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
8
src/core/llm/TextLlmTool.ts
Normal file
8
src/core/llm/TextLlmTool.ts
Normal 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
26
src/core/llm/Tracer.ts
Normal 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>;
|
||||
}
|
||||
12
src/core/logging/logger.ts
Normal file
12
src/core/logging/logger.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
5
src/core/server/Controller.ts
Normal file
5
src/core/server/Controller.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
export abstract class Controller {
|
||||
abstract register(app: FastifyInstance): void;
|
||||
}
|
||||
30
src/core/server/authHook.ts
Normal file
30
src/core/server/authHook.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
17
src/core/server/buildServer.ts
Normal file
17
src/core/server/buildServer.ts
Normal 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;
|
||||
}
|
||||
72
test/app/clients/jellyfin/JellyfinClient.test.ts
Normal file
72
test/app/clients/jellyfin/JellyfinClient.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
64
test/app/clients/radarr/RadarrClient.test.ts
Normal file
64
test/app/clients/radarr/RadarrClient.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
52
test/app/clients/tmdb/TmdbClient.test.ts
Normal file
52
test/app/clients/tmdb/TmdbClient.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
45
test/app/config/AppConfig.test.ts
Normal file
45
test/app/config/AppConfig.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
15
test/app/controllers/HealthController.test.ts
Normal file
15
test/app/controllers/HealthController.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
80
test/app/controllers/RecommendationsController.test.ts
Normal file
80
test/app/controllers/RecommendationsController.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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('falls back to the configured count for a malformed or non-positive count query param', async () => {
|
||||
const runAsync = vi.fn().mockResolvedValue([]);
|
||||
const app = makeApp({ RECOMMEND_COUNT: 10, LLM_ENABLED: true }, { runAsync } as unknown as RecommendationPipeline);
|
||||
|
||||
await app.inject({ method: 'GET', url: '/recommendations?count=abc' });
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
|
||||
|
||||
await app.inject({ method: 'GET', url: '/recommendations?count=-5' });
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
|
||||
|
||||
await app.inject({ method: 'GET', url: '/recommendations?count=0' });
|
||||
expect(runAsync).toHaveBeenCalledWith({ count: 10, 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);
|
||||
});
|
||||
});
|
||||
36
test/app/controllers/RequestController.test.ts
Normal file
36
test/app/controllers/RequestController.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
31
test/app/controllers/WebhookController.test.ts
Normal file
31
test/app/controllers/WebhookController.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
429
test/app/pipelines/RecommendationPipeline.test.ts
Normal file
429
test/app/pipelines/RecommendationPipeline.test.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
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' }]);
|
||||
});
|
||||
|
||||
it('continues with an incomplete exclusion set when fetching the library fails', async () => {
|
||||
const jellyfin = {
|
||||
getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]),
|
||||
getRecentlyWatched: vi.fn(),
|
||||
getAllMovies: vi.fn().mockRejectedValue(new Error('jellyfin down')),
|
||||
} as unknown as JellyfinClient;
|
||||
const tmdb = {
|
||||
searchMovie: vi.fn().mockResolvedValue({ id: 3, title: 'Interstellar', year: 2014 } as TMDbMovie),
|
||||
} as unknown as TmdbClient;
|
||||
const tool = {
|
||||
runAsync: vi.fn().mockResolvedValue([{ title: 'Interstellar', year: 2014, reason: 'r' }]),
|
||||
} as unknown as MovieRecommenderTool;
|
||||
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
|
||||
const logger = { warn: vi.fn() } as unknown as Logger;
|
||||
|
||||
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
|
||||
jellyfin,
|
||||
tmdb,
|
||||
tool,
|
||||
searchTool,
|
||||
minRatedMovies: 1,
|
||||
maxRetry: 0,
|
||||
});
|
||||
|
||||
const result = await pipeline.runAsync({ count: 1, useLlm: true });
|
||||
|
||||
expect(result).toEqual([{ title: 'Interstellar', year: 2014, tmdbId: 3, reason: 'r' }]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not fetch library; exclusion may be incomplete'
|
||||
);
|
||||
});
|
||||
|
||||
it('skips a suggestion whose TMDb resolution fails instead of aborting the run', 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()
|
||||
.mockRejectedValueOnce(new Error('tmdb down'))
|
||||
.mockResolvedValueOnce({ id: 3, title: 'Interstellar', year: 2014 } as TMDbMovie),
|
||||
} as unknown as TmdbClient;
|
||||
const tool = {
|
||||
runAsync: vi.fn().mockResolvedValue([
|
||||
{ title: 'Unresolvable', year: 1999, reason: 'r1' },
|
||||
{ title: 'Interstellar', year: 2014, reason: 'r2' },
|
||||
]),
|
||||
} as unknown as MovieRecommenderTool;
|
||||
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
|
||||
const logger = { warn: vi.fn() } as unknown as Logger;
|
||||
|
||||
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
|
||||
jellyfin,
|
||||
tmdb,
|
||||
tool,
|
||||
searchTool,
|
||||
minRatedMovies: 1,
|
||||
maxRetry: 0,
|
||||
});
|
||||
|
||||
const result = await pipeline.runAsync({ count: 2, useLlm: true });
|
||||
|
||||
expect(result).toEqual([{ title: 'Interstellar', year: 2014, tmdbId: 3, reason: 'r2' }]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error), title: 'Unresolvable' }),
|
||||
'TMDb search failed, skipping suggestion'
|
||||
);
|
||||
});
|
||||
|
||||
it('skips a signal movie whose TMDb similar/recommendations lookup fails in the fallback path', async () => {
|
||||
const jellyfin = {
|
||||
getFavoriteMovies: vi
|
||||
.fn()
|
||||
.mockResolvedValue([movie('Broken', 2000, '1'), movie('Dune', 2021, '2')]),
|
||||
getRecentlyWatched: vi.fn(),
|
||||
getAllMovies: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as JellyfinClient;
|
||||
const tmdb = {
|
||||
getSimilar: vi.fn().mockImplementation((id: number) => {
|
||||
if (id === 1) return Promise.reject(new Error('tmdb down'));
|
||||
return Promise.resolve([{ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie]);
|
||||
}),
|
||||
getRecommendations: vi.fn().mockImplementation((id: number) => {
|
||||
if (id === 1) return Promise.reject(new Error('tmdb down'));
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
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 logger = { warn: vi.fn() } as unknown as Logger;
|
||||
|
||||
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
|
||||
jellyfin,
|
||||
tmdb,
|
||||
tool,
|
||||
searchTool,
|
||||
minRatedMovies: 2,
|
||||
maxRetry: 0,
|
||||
});
|
||||
|
||||
const result = await pipeline.runAsync({ count: 1, useLlm: false });
|
||||
|
||||
expect(result).toEqual([{ title: 'Similar A', year: 2010, tmdbId: 20, reason: 'Recommended by TMDb' }]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error), tmdbId: 1 }),
|
||||
'TMDb similar/recommendations failed, skipping signal movie'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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());
|
||||
});
|
||||
|
||||
it('continues with an empty exclusion set when fetching the library fails', async () => {
|
||||
const jellyfin = {
|
||||
getAllMovies: vi.fn().mockRejectedValue(new Error('jellyfin down')),
|
||||
} 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 logger = { warn: vi.fn() } as unknown as Logger;
|
||||
|
||||
const pipeline = new RecommendationPipeline(makeTracer(), 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(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not fetch library; exclusion may be incomplete'
|
||||
);
|
||||
});
|
||||
});
|
||||
60
test/app/tools/MovieRecommenderTool.test.ts
Normal file
60
test/app/tools/MovieRecommenderTool.test.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
15
test/app/tools/MovieSearchTool.test.ts
Normal file
15
test/app/tools/MovieSearchTool.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
19
test/core/config/env.test.ts
Normal file
19
test/core/config/env.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
111
test/core/http/ApiClient.test.ts
Normal file
111
test/core/http/ApiClient.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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);
|
||||
});
|
||||
|
||||
it('omits the query string (which may carry secrets) from the ApiError message', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' });
|
||||
|
||||
const client = new TestClient('https://api.example.com');
|
||||
await expect(client.getThing(1)).rejects.toThrow('GET https://api.example.com/things/1 returned 500');
|
||||
});
|
||||
});
|
||||
23
test/core/langfuse/LangfusePromptGateway.test.ts
Normal file
23
test/core/langfuse/LangfusePromptGateway.test.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
42
test/core/langfuse/LangfuseTracer.test.ts
Normal file
42
test/core/langfuse/LangfuseTracer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
test/core/langfuse/PromptService.test.ts
Normal file
64
test/core/langfuse/PromptService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
43
test/core/llm/LlmClient.test.ts
Normal file
43
test/core/llm/LlmClient.test.ts
Normal 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/
|
||||
);
|
||||
});
|
||||
});
|
||||
74
test/core/llm/LlmPipeline.test.ts
Normal file
74
test/core/llm/LlmPipeline.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
115
test/core/llm/LlmTool.test.ts
Normal file
115
test/core/llm/LlmTool.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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('falls back to the constructor-provided defaultModel when the prompt config omits model', async () => {
|
||||
const getCompiled = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [{ role: 'user', content: 'hi' }] });
|
||||
const promptService = { getCompiled } as unknown as PromptService;
|
||||
const chat = vi.fn().mockResolvedValue({ content: 'hi' });
|
||||
const llmClient = { chat } as unknown as LlmClient;
|
||||
|
||||
const { ctx } = makeCtx();
|
||||
const tool = new GreetTool(promptService, llmClient, 'llama3');
|
||||
|
||||
await tool.runAsync({ who: 'alice' }, ctx);
|
||||
|
||||
expect(chat).toHaveBeenCalledWith(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
{ model: 'llama3', temperature: undefined, maxTokens: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to gpt-4o-mini when no defaultModel is given and the prompt config omits model', async () => {
|
||||
const getCompiled = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [{ role: 'user', content: 'hi' }] });
|
||||
const promptService = { getCompiled } as unknown as PromptService;
|
||||
const chat = vi.fn().mockResolvedValue({ content: 'hi' });
|
||||
const llmClient = { chat } as unknown as LlmClient;
|
||||
|
||||
const { ctx } = makeCtx();
|
||||
const tool = new GreetTool(promptService, llmClient);
|
||||
|
||||
await tool.runAsync({ who: 'alice' }, ctx);
|
||||
|
||||
expect(chat).toHaveBeenCalledWith(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
{ model: 'gpt-4o-mini', temperature: undefined, maxTokens: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
54
test/core/llm/SchemaLlmTool.test.ts
Normal file
54
test/core/llm/SchemaLlmTool.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
test/core/llm/TextLlmTool.test.ts
Normal file
21
test/core/llm/TextLlmTool.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
11
test/core/logging/logger.test.ts
Normal file
11
test/core/logging/logger.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
41
test/core/server/authHook.test.ts
Normal file
41
test/core/server/authHook.test.ts
Normal 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
17
tsconfig.json
Normal 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
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user