42 Commits

Author SHA1 Message Date
2c82a8416f Document OpenRouter as a supported LLM_BASE_URL provider 2026-08-12 23:43:51 +02:00
cb5e7085bf Support OpenRouter's real per-request cost reporting
Send usage: { include: true } on every chat completion request (an
OpenRouter-specific extension - requires Usage Accounting enabled on
the OpenRouter account; other OpenAI-compatible providers generally
ignore unknown top-level request fields). When the response includes
OpenRouter's own usage.cost, forward it to Langfuse as
costDetails.total, so cost is the real per-request figure OpenRouter
billed rather than a guess from Langfuse's model price list (which
has no entry for proxied/aggregated OpenRouter model names anyway).

Verified: full suite (84/84) passing, tsc/eslint clean, docker
compose build succeeds. Per Langfuse's OpenRouter integration guide:
https://langfuse.com/integrations/gateways/openrouter
2026-08-12 23:43:30 +02:00
9a4d254968 Fix input/output getting misrouted by mixed usage-schema keys
The previous cached-tokens commit added cache_read_input_tokens (a
flat-schema key) alongside prompt_tokens/completion_tokens (OpenAI's
native schema) in the same usageDetails object. Langfuse's ingestion
explicitly does not support mixing those two schemas, and doing so
broke generation.update()'s output field - confirmed live: prompt/
response content was landing in the Usage panel instead of Input/
Output.

Fixed by staying entirely within OpenAI's native schema: cached
tokens now nest under prompt_tokens_details.cached_tokens instead of
using the flat cache_read_input_tokens key. Cast past the installed
@langfuse/tracing SDK's usageDetails type (which only exposes a
3-field camelCase shape with no _details nesting, unlike the wire
schema in @langfuse/core) since the nesting is valid on the wire even
though the SDK's public type doesn't model it.

Verified: full suite (82/82) passing, tsc/eslint clean.
2026-08-12 23:24:09 +02:00
df4ca0bf76 Track cached prompt tokens through to Langfuse
OpenAI's chat completions response includes prompt_tokens_details.
cached_tokens when prompt caching kicks in - previously dropped
entirely. Now extracted in LlmClient and forwarded to Langfuse as the
flat cache_read_input_tokens usage key.

Used the flat key rather than OpenAI's nested prompt_tokens_details
shape: the installed @langfuse/tracing SDK's usageDetails type is
Record<string, number> | OpenAiUsage (a 3-field camelCase shape, no
_details nesting) even though the wire schema in @langfuse/core
supports nesting - a gap in the SDK's public types, not something
worth fighting with a cast.

Verified: full suite (82/82) passing, tsc/eslint clean.
2026-08-12 23:15:42 +02:00
6f0300f038 Fix Langfuse usage tracking: map to snake_case keys it recognizes
LangfuseTracer was passing our internal camelCase usage fields
(promptTokens/completionTokens/totalTokens) straight through as
usageDetails. Langfuse only recognizes a flat schema (input/output/...)
or OpenAI's native snake_case schema (prompt_tokens/completion_tokens/
total_tokens) for usage/cost calculation - anything else is silently
ignored, so every generation was showing zero usage regardless of
what LlmClient actually reported.

Note: cost still requires a price-list entry for the model name.
Langfuse's built-in list only covers hosted providers (OpenAI,
Anthropic, Google); self-hosted models (e.g. LLM_MODEL=llama3 via
Ollama) need a custom model price added in Project Settings > Models
for a $ figure to show up, even with token counts now correct.

Verified: full suite (80/80) passing, tsc/eslint clean.
2026-08-12 23:00:04 +02:00
e47c57b50d Group pipeline runs into Langfuse sessions, tagged by trigger source
Each pipeline invocation (runAsync/searchAsync) now gets a fresh
sessionId, wrapped via the new Tracer.runWithSession() (implemented
with @langfuse/tracing's propagateAttributes, since v5 has no direct
sessionId/tags option on startObservation) so it shows up in
Langfuse's Sessions view. The session is also tagged with what
triggered the run: "cron", "api-list", "api-trigger", "api-search".

Verified: full test suite (79/79) passing, tsc/eslint clean, real
docker compose build + container smoke test against /health.
2026-08-12 22:51:17 +02:00
b615f08039 Copy pnpm-workspace.yaml into Docker build so protobufjs build-script
approval (needed by the new OTEL-based Langfuse SDK) actually takes
effect inside the image, not just locally.

Verified with docker compose build --no-cache + a real container run
against /health.
2026-08-12 22:21:15 +02:00
a5120d5153 upgrade langfuse 2026-08-12 22:17:56 +02:00
2ffba19392 add langfuse url to server starting 2026-08-12 22:03:04 +02:00
a537f22384 fix network 2026-08-12 21:52:45 +02:00
a90f9dc19b add coolify network 2026-08-12 21:46:32 +02:00
83bb16e2a0 Remove Go implementation, promote TypeScript app as canonical
The TypeScript/OOP rewrite has reached full functional parity and is
now the only implementation:

- Delete cmd/, internal/, go.mod, go.sum.
- Promote Dockerfile.node -> Dockerfile and docker-compose.node.yml ->
  docker-compose.yml (service renamed sugarr-node -> sugarr).
- Update README and .gitignore to drop the "two implementations"
  framing and Go-specific entries.

Verified after the rename: tsc --noEmit clean, full test suite
passing (75/75), docker compose build succeeds.
2026-08-12 21:45:32 +02:00
ae8bfaa2eb Fix final review findings: key leak, model fallback, error resilience, docs
- ApiClient: strip query string from ApiError messages so API keys
  (e.g. TMDb's api_key query param) never leak into 500 response
  bodies or logs.
- LlmTool: accept an optional defaultModel, wired from AppConfig's
  LLM_MODEL, so Langfuse prompts that omit a model config fall back
  to the configured LLM rather than silently requesting gpt-4o-mini.
- RecommendationPipeline: restore the Go original's graceful
  degradation — library/Radarr fetch failures, per-suggestion TMDb
  resolution failures, and per-signal TMDb similarity/recommendation
  failures are now logged and skipped instead of aborting the whole
  run (applies to both runInternal and searchAsync).
- RecommendationsController: validate the ?count= query param
  (must be a positive integer) instead of silently producing NaN.
- Commit the Langfuse prompt definitions (langfuse/*.prompt.json) and
  add a README covering setup, prompt seeding, cache invalidation,
  and the HTTP API surface — the branch was not otherwise runnable by
  anyone but the author.
2026-08-12 21:40:09 +02:00
f26a5d72b2 Add Docker packaging for the TypeScript app 2026-08-12 21:30:32 +02:00
31de7f15db Wire up cron scheduling and the app entrypoint 2026-08-12 21:23:08 +02:00
e712470013 Add Health/Recommendations/Request/Webhook controllers 2026-08-12 21:21:26 +02:00
3c5f723d22 Isolate per-item Radarr failures in RecommendationPipeline.autoRequest
Matches the Go original's behavior: a lookup/addMovie failure for one
recommendation is logged and skipped rather than aborting the whole
auto-request batch.
2026-08-12 21:18:33 +02:00
62848f6ecd Add RecommendationPipeline 2026-08-12 21:15:21 +02:00
5edeb0ebb9 Add MovieRecommenderTool and MovieSearchTool 2026-08-12 21:14:10 +02:00
5140bc5a50 Widen loadEnv schema type and allow rest-sibling destructuring in lint
loadEnv's ZodSchema<T> parameter required schema input === output,
which breaks any schema using .transform() (e.g. AppConfigSchema's
boolean coercion). Widen to ZodType<T, ZodTypeDef, unknown>, matching
existing runtime behavior. Also allow ignoreRestSiblings in the
no-unused-vars rule so 'const { X, ...rest } = obj' omission tests
don't trip the linter.
2026-08-12 21:08:21 +02:00
8e908408cd Add RadarrClient 2026-08-12 21:05:58 +02:00
4e483557d7 Add TmdbClient 2026-08-12 21:05:22 +02:00
9683d65a1f Add JellyfinClient 2026-08-12 21:04:47 +02:00
ef037d25fe Add app config schema and shared models 2026-08-12 21:04:15 +02:00
69db1d5558 Harden auth hook and fix eslint config for intentional unused args
- authHook: use crypto.timingSafeEqual for the bearer token comparison
  instead of !==, avoiding a timing side-channel on the secret key.
- .eslintrc.cjs: add argsIgnorePattern for underscore-prefixed unused
  params, fixing false-positive no-unused-vars errors on intentional
  _ctx/_params placeholders used throughout the LlmTool hierarchy.
2026-08-12 21:00:44 +02:00
bb479f7749 Fix LangfusePromptGateway compile() to match langfuse SDK's Record<string,string> variable type 2026-08-12 20:56:39 +02:00
cf3c12774f Add concrete Langfuse tracer and prompt gateway 2026-08-12 20:55:51 +02:00
299375bcd3 Add core server Controller base, auth hook, and buildServer 2026-08-12 20:55:06 +02:00
3f99bce3c6 Add core LlmPipeline base class 2026-08-12 20:54:38 +02:00
8d468903a4 Add SchemaLlmTool and TextLlmTool 2026-08-12 20:54:10 +02:00
ee4aba83e8 Add core Tracer/RunContext types and LlmTool base class 2026-08-12 20:53:27 +02:00
3ca58194e6 Add core LlmClient wrapping OpenAI-compatible chat completions 2026-08-12 20:52:36 +02:00
81e9a20b14 Add cached Langfuse PromptService 2026-08-12 20:52:10 +02:00
580357a130 Add core Endpoint/ApiClient base classes 2026-08-12 20:51:36 +02:00
ca08ed9294 Add core env loader 2026-08-12 20:51:03 +02:00
a9a8fa7c3f Scaffold TypeScript project and add core logger 2026-08-12 20:50:40 +02:00
abfbc1e2da Add implementation plan for TypeScript/OOP rewrite 2026-08-12 20:35:35 +02:00
16e61a77e6 Add design spec for TypeScript/OOP rewrite of sugarr 2026-08-12 19:48:03 +02:00
1167d66585 fix error 2026-04-25 21:27:15 +02:00
58a2b00e3d add version 2026-04-25 21:21:44 +02:00
8e37ff2ed6 add missing env to compose 2026-04-25 21:16:13 +02:00
5dd8902472 retry if no new suggestions 2026-04-25 21:07:15 +02:00
94 changed files with 11495 additions and 1325 deletions

43
.env.example Normal file
View File

@@ -0,0 +1,43 @@
# Required
JELLYFIN_URL=
JELLYFIN_API_KEY=
JELLYFIN_USER_ID=
TMDB_API_KEY=
# LLM (disabled by default — any OpenAI-compatible endpoint: Ollama, OpenAI,
# LM Studio, OpenRouter, etc.)
# For OpenRouter specifically: LLM_BASE_URL=https://openrouter.ai/api/v1,
# LLM_API_KEY=<your OpenRouter key>, LLM_MODEL=<an OpenRouter model slug>.
# Enable "Usage Accounting" on the OpenRouter account to get real per-request
# cost reported back to Langfuse instead of a price-list estimate.
LLM_ENABLED=false
LLM_BASE_URL=http://localhost:11434/v1
LLM_API_KEY=
LLM_MODEL=llama3
# Radarr (disabled by default)
RADARR_ENABLED=false
RADARR_URL=
RADARR_API_KEY=
RADARR_QUALITY_PROFILE_ID=1
RADARR_ROOT_FOLDER=/movies
# Cron auto-run (disabled by default)
CRON_ENABLED=false
CRON_SCHEDULE=0 3 * * *
# HTTP API (enabled by default)
API_ENABLED=true
API_PORT=8080
SECRET_KEY=
# Tuning
RECOMMEND_COUNT=10
RECOMMEND_MAX_RETRY=3
MIN_RATED_MOVIES=5
# Langfuse (required by the TypeScript app)
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
LANGFUSE_BASE_URL=
LANGFUSE_PROMPT_CACHE_ENABLED=true

15
.eslintrc.cjs Normal file
View File

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

39
.gitignore vendored
View File

@@ -1,39 +1,10 @@
# ---> 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/
*.log

6
.prettierrc.json Normal file
View File

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

View File

@@ -1,11 +1,17 @@
FROM golang:1.24-alpine AS build
FROM node:22-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /sugarr ./cmd/sugarr
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile --ignore-scripts
COPY tsconfig.json ./
COPY src ./src
RUN pnpm build
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=build /sugarr /usr/local/bin/
ENTRYPOINT ["sugarr"]
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile --prod
COPY --from=build /app/dist ./dist
ENTRYPOINT ["node", "dist/app/index.js"]

92
README.md Normal file
View File

@@ -0,0 +1,92 @@
# 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.
Built with a TypeScript/OOP architecture (Node + Fastify + Langfuse): a
reusable `src/core/` framework (HTTP client base classes, an LLM tool/pipeline
abstraction with Langfuse-cached prompts, Fastify server scaffolding) and an
`src/app/` layer with Sugarr's concrete clients/tools/pipeline/controllers.
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 (this repo previously also had a Go implementation,
now removed in favor of this one).
## Running the 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 up -d --build
```
## 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>`.

View File

@@ -1,102 +0,0 @@
package main
import (
"fmt"
"os"
"text/tabwriter"
"github.com/fjurmanovic/sugarr/internal/model"
"github.com/fjurmanovic/sugarr/internal/service"
"github.com/fjurmanovic/sugarr/internal/utl/configs"
"github.com/fjurmanovic/sugarr/internal/utl/server"
"github.com/robfig/cron/v3"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func main() {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
cfg, err := configs.Load()
if err != nil {
log.Fatal().Err(err).Msg("Failed to load config")
}
printBanner(cfg)
rec := service.NewRecommender(cfg)
// Cron scheduler
if cfg.CronEnabled {
c := cron.New()
if _, err := c.AddFunc(cfg.CronSchedule, func() {
log.Info().Msg("Cron: running recommendation pipeline")
recs, err := rec.Run()
if err != nil {
log.Error().Err(err).Msg("Cron: pipeline failed")
return
}
if cfg.RadarrEnabled {
rec.AutoRequest(recs)
}
logRecommendations(recs)
}); err != nil {
log.Fatal().Err(err).Msg("Invalid cron schedule")
}
c.Start()
defer c.Stop()
log.Info().Msgf("Cron scheduled: %s", cfg.CronSchedule)
}
// HTTP API — blocks until server exits
if cfg.APIEnabled {
if err := server.Start(cfg, rec); err != nil {
log.Fatal().Err(err).Msg("HTTP server failed")
}
return
}
// One-shot mode: no API, no cron
if !cfg.CronEnabled {
recs, err := rec.Run()
if err != nil {
log.Fatal().Err(err).Msg("Recommendation pipeline failed")
}
printTable(recs)
return
}
// Cron-only mode: block forever
select {}
}
func printBanner(cfg *configs.Config) {
log.Info().Msg("sugarr starting")
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)
log.Info().Msgf(" Cron: enabled=%v schedule=%s", cfg.CronEnabled, cfg.CronSchedule)
log.Info().Msgf(" API: enabled=%v port=%d", cfg.APIEnabled, cfg.APIPort)
log.Info().Msgf(" Count: %d recommendations", cfg.RecommendCount)
}
func logRecommendations(recs []model.Recommendation) {
log.Info().Msgf("Recommendations (%d):", len(recs))
for i, r := range recs {
log.Info().Msgf(" %d. %s (%d) [tmdb:%d] — %s", i+1, r.Title, r.Year, r.TMDbID, r.Reason)
}
}
func printTable(recs []model.Recommendation) {
if len(recs) == 0 {
fmt.Println("No recommendations found.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "#\tTitle\tYear\tTMDb ID\tReason")
fmt.Fprintln(w, "-\t-----\t----\t-------\t------")
for i, r := range recs {
fmt.Fprintf(w, "%d\t%s\t%d\t%d\t%s\n", i+1, r.Title, r.Year, r.TMDbID, r.Reason)
}
w.Flush()
}

View File

@@ -1,10 +1,12 @@
services:
sugarr:
build: .
build:
context: .
dockerfile: Dockerfile
container_name: sugarr
restart: unless-stopped
ports:
- "${API_PORT}:${API_PORT}"
- '${API_PORT}:${API_PORT}'
environment:
JELLYFIN_URL: ${JELLYFIN_URL}
JELLYFIN_API_KEY: ${JELLYFIN_API_KEY}
@@ -23,11 +25,17 @@ services:
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
- coolify
networks:
media:
coolify:
external: true

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
# Sugarr TypeScript/OOP Rewrite — Design
**Date:** 2026-08-12
**Branch:** `feature/typescript-rewrite`
**Status:** Approved for planning
## Background
Sugarr is currently a Go application (`cmd/`, `internal/`) that recommends movies:
it pulls favorite/recently-watched movies from Jellyfin, gets suggestions either
from an OpenAI-compatible LLM or a TMDb similarity/frequency fallback, resolves
suggestions against TMDb, excludes anything already in the Jellyfin library or
Radarr, optionally auto-requests via Radarr, and exposes this via a cron job and
an HTTP API (`/health`, `/recommendations`, `/recommendations/search`,
`/recommendations/trigger`, `/recommendations/request`), with bearer-token auth.
The Go implementation stays untouched during this work. This spec covers a
parallel Node/TypeScript rewrite with a reusable, OOP "core" architecture
(intended to be copyable into future projects) and an "app" layer with
Sugarr's specific tools/pipelines/clients. Prompts and per-prompt model
options move out of hardcoded Go strings and into Langfuse, cached in memory
with a webhook-triggered invalidation path.
Cutover (deleting the Go code, promoting the Node Dockerfile/compose) happens
in a later pass once the TS version reaches functional parity — out of scope
for this spec.
## Goals
- Full OOP core: `ApiClient`/`Endpoint`, `LlmTool` (+ `SchemaLlmTool` /
`TextLlmTool`), `LlmPipeline`.
- Langfuse-backed prompts (text + per-prompt `config` for model/temperature/etc),
in-memory cached, with an env flag to disable caching and a webhook endpoint
to invalidate the cache.
- Functional parity with the current Go app's recommendation pipeline and HTTP
API surface.
- Folder structure that cleanly separates reusable `core/` from Sugarr-specific
`app/` code.
- Runs in Docker, independent of the existing Go Dockerfile/compose.
## Non-goals
- Changing the public HTTP route shapes/methods/bodies (kept identical to Go,
plus one new webhook route).
- Removing or modifying the Go implementation.
- Standing up a new Langfuse instance — an existing instance is assumed
reachable via env-configured URL/keys.
- Persistent storage / database — the app remains stateless, same as today.
## Repo layout
```
sugarr/
cmd/, internal/, Dockerfile, docker-compose.yml # existing Go app, untouched
src/
core/ # reusable, copyable to future projects
config/ env.ts # zod-based env loader helper
logging/ logger.ts # pino logger factory
http/ ApiClient.ts, Endpoint.ts
langfuse/ PromptService.ts, PromptCache.ts, types.ts
llm/ LlmClient.ts, LlmTool.ts, SchemaLlmTool.ts, TextLlmTool.ts,
LlmPipeline.ts, types.ts (RunContext, etc.)
server/ Controller.ts, buildServer.ts, authHook.ts
app/ # sugarr-specific
config/ AppConfig.ts
clients/
jellyfin/ JellyfinClient.ts, endpoints/*.ts
tmdb/ TmdbClient.ts, endpoints/*.ts
radarr/ RadarrClient.ts, endpoints/*.ts
tools/ MovieRecommenderTool.ts
pipelines/ RecommendationPipeline.ts
controllers/RecommendationsController.ts, RequestController.ts,
HealthController.ts, WebhookController.ts
cron.ts
index.ts # entrypoint: config, cron, Fastify server
test/ # vitest, mirrors src/
Dockerfile.node
docker-compose.node.yml
package.json, tsconfig.json, .env.example
```
At cutover time (future, out of scope here): rename `Dockerfile.node` →
`Dockerfile`, merge compose files, delete `cmd/`, `internal/`, `go.mod`,
`go.sum`.
## Core architecture
### `ApiClient` / `Endpoint`
- `abstract class Endpoint<TResponse, TParams = void>` declares `method`,
`path(params)`, optional `query(params)` / `body(params)`, and
`parseResponse(json): TResponse`.
- `abstract class ApiClient` holds `baseUrl` and default headers, exposes
`protected request<T>(endpoint: Endpoint<T, P>, params?: P): Promise<T>`
which builds the request, performs `fetch`, checks status codes, and
delegates response parsing to the endpoint. A protected hook
(`protected buildHeaders(): Record<string,string>`) lets subclasses inject
per-client auth (Jellyfin token header, Radarr API key header, TMDb query
param).
- App clients (`JellyfinClient`, `TmdbClient`, `RadarrClient`) extend
`ApiClient`; each exposes typed methods that construct the relevant
`Endpoint` and call `request()`.
### `LlmTool` hierarchy
```ts
interface RunContext {
trace: LangfuseTraceHandle;
logger: Logger;
}
abstract class LlmTool<TInput, TOutput> {
protected abstract readonly promptName: string; // Langfuse prompt identifier
protected abstract buildVariables(input: TInput, ctx: RunContext): Record<string, unknown>;
protected async preProcess(input: TInput, ctx: RunContext): Promise<TInput> { return input; }
protected async postProcess(output: TOutput, input: TInput, ctx: RunContext): Promise<TOutput> { return output; }
protected abstract parseOutput(raw: string, ctx: RunContext): TOutput;
async runAsync(input: TInput, ctx: RunContext): Promise<TOutput> {
// 1. fetch+compile prompt+config via PromptService (cached)
// 2. input = await preProcess(input, ctx)
// 3. call LlmClient.chat(messages, config) inside a Langfuse generation
// nested under ctx.trace
// 4. output = parseOutput(rawText, ctx)
// 5. output = await postProcess(output, input, ctx)
// 6. log via app logger + Langfuse generation metadata; return output
}
}
```
Two abstract subclasses implement `parseOutput`:
- `abstract class SchemaLlmTool<TInput, TOutput> extends LlmTool<TInput, TOutput>`
adds `protected abstract readonly outputSchema: ZodSchema<TOutput>`.
`parseOutput` strips markdown code fences, extracts the JSON
array/object (same recovery approach as today's `parseMovieSuggestions`),
parses it, and validates against `outputSchema`, throwing a descriptive
error (recorded on the trace) on failure.
- `abstract class TextLlmTool<TInput> extends LlmTool<TInput, string>`
implements `parseOutput` as a trimmed passthrough of the raw text — for
tools that don't need a JSON contract.
Concrete: `MovieRecommenderTool extends SchemaLlmTool<RecommendInput, MovieSuggestion[]>`,
with `outputSchema = z.array(z.object({ title: z.string(), year: z.number(), reason: z.string() }))`.
### `PromptService` (Langfuse prompt cache)
- Wraps the Langfuse SDK's prompt-fetch call. In-memory
`Map<promptName, { prompt, config, fetchedAt }>`.
- `LANGFUSE_PROMPT_CACHE_ENABLED` env var (default `true`); when `false`,
every fetch bypasses the cache and goes straight to Langfuse.
- `invalidate(promptName?: string)` clears one entry, or the whole cache when
no name is given.
- Exposed via `WebhookController`: `POST /webhooks/langfuse/invalidate` with
body `{ promptName?: string }`, protected by the same bearer secret as the
rest of the API. This is designed to be called manually, from CI, or from
an external trigger (Langfuse Cloud webhook, if configured) — Langfuse
self-host has no built-in "prompt updated" push, so this is a
pull-invalidation surface, not a guaranteed auto-sync.
### `LlmPipeline`
```ts
abstract class LlmPipeline<TInput, TOutput> {
protected abstract runInternal(input: TInput, ctx: RunContext): Promise<TOutput>;
async runAsync(input: TInput): Promise<TOutput> {
// opens one Langfuse trace for the whole run, builds RunContext{trace},
// calls runInternal(input, ctx), flushes the trace, returns result
}
protected runTool<I, O>(tool: LlmTool<I, O>, input: I, ctx: RunContext): Promise<O> {
// runs a tool as a nested span under ctx.trace, same trace id as the pipeline
}
}
```
Concrete: `RecommendationPipeline extends LlmPipeline<RecommendationRequest, Recommendation[]>`
owns what `Recommender.RunWithCount` does today:
1. Fetch favorites; if `favorites.length < MIN_RATED_MOVIES`, fall back to
recently-watched as the signal set.
2. Build the exclusion set from the full Jellyfin library plus (if Radarr is
enabled) Radarr's existing movies.
3. Retry loop (bounded by `RECOMMEND_MAX_RETRY`): each attempt asks for the
remaining needed count, either via `MovieRecommenderTool.runAsync` (LLM
enabled) or the TMDb similarity/frequency fallback (LLM disabled);
resolves each suggestion via TMDb search; accumulates non-excluded results;
the running list of resolved suggestions across attempts feeds back into
the tool's `buildVariables` so retries don't repeat themselves. All under
one trace id.
4. `autoRequest(recommendations)` — separate method, called by the cron job
and the `/recommendations/trigger` route (not by plain `GET /recommendations`),
matching today's behavior — adds each recommendation to Radarr.
## HTTP layer (Fastify)
Routes, identical to Go, plus the new webhook:
| Method | Path | Notes |
|---|---|---|
| GET | `/health` | exempt from auth |
| GET | `/recommendations` | `?count=`, `?force_llm=true`, `?force_tmdb=true` |
| POST | `/recommendations/search` | body `{ query: string }` |
| POST | `/recommendations/trigger` | runs pipeline + auto-request if Radarr enabled |
| POST | `/recommendations/request` | body `{ tmdb_id: number }` |
| POST | `/webhooks/langfuse/invalidate` | body `{ promptName?: string }` — new |
- `abstract class Controller { abstract register(app: FastifyInstance): void }`.
- Bearer auth as a Fastify `onRequest` hook, secret from `API_SECRET_KEY`
env var, `/health` exempt — same semantics as `httputil.AuthMiddleware`.
## Config, logging, LLM client
- `AppConfig`: zod schema over `process.env` (loaded via `dotenv`). Same env
var names as the Go app (`JELLYFIN_URL`, `JELLYFIN_API_KEY`,
`JELLYFIN_USER_ID`, `TMDB_API_KEY`, `LLM_ENABLED`, `LLM_BASE_URL`,
`LLM_API_KEY`, `LLM_MODEL`, `RADARR_ENABLED`, `RADARR_URL`,
`RADARR_API_KEY`, `RADARR_QUALITY_PROFILE_ID`, `RADARR_ROOT_FOLDER`,
`CRON_ENABLED`, `CRON_SCHEDULE`, `API_ENABLED`, `API_PORT`, `SECRET_KEY`,
`RECOMMEND_COUNT`, `RECOMMEND_MAX_RETRY`, `MIN_RATED_MOVIES`), plus new
`LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_BASE_URL`,
`LANGFUSE_PROMPT_CACHE_ENABLED`.
- `logger.ts`: pino, structured JSON logging, replaces zerolog's console
writer with an equivalent pretty-print transport in development.
- `LlmClient` (`core/llm`): thin wrapper over the `openai` npm SDK pointed at
`LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL` — works against Ollama, LM Studio,
or OpenAI itself, same as the current Go client. Called from inside
`LlmTool.runAsync`, wrapped in a Langfuse generation.
- Cron: `node-cron`, driven by `CRON_ENABLED`/`CRON_SCHEDULE`, calling
`RecommendationPipeline.runAsync` then `autoRequest` if Radarr is enabled —
same as the Go `main.go` cron branch.
## Testing
- Vitest for unit tests.
- `core`: `LlmTool`/`SchemaLlmTool`/`TextLlmTool` orchestration (mocked
`PromptService`/`LlmClient`), `PromptService` cache hit/miss/invalidate/
disabled-cache behavior, `ApiClient` request building.
- `app`: `RecommendationPipeline` retry/exclusion logic, TMDb-fallback
ranking, client response parsing (Jellyfin item → `Movie` mapping, TMDb
search → `TMDbMovie` mapping, Radarr add/lookup).
## Tooling
- pnpm, Node (per CLAUDE.md environment, Node 25.6.1 available).
- `tsx` for dev-run, `tsc` for production build.
- ESLint + Prettier.
- `Dockerfile.node` (multi-stage: install → build → slim runtime image) and
`docker-compose.node.yml` (mirrors the existing compose file's env var
wiring, adds the four new `LANGFUSE_*` vars), kept separate from the Go
Dockerfile/compose until cutover.
## Open questions
None outstanding — all resolved during brainstorming (runtime/package
manager, HTTP framework, Langfuse hosting, Go-code disposition, prompt
config mechanism, test framework, API-surface scope).

16
go.mod
View File

@@ -1,16 +0,0 @@
module github.com/fjurmanovic/sugarr
go 1.24.2
require (
github.com/joho/godotenv v1.5.1
github.com/kelseyhightower/envconfig v1.4.0
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.34.0
)
require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/sys v0.28.0 // indirect
)

22
go.sum
View File

@@ -1,22 +0,0 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -1,16 +0,0 @@
package api
import (
"net/http"
"github.com/fjurmanovic/sugarr/internal/controller"
"github.com/fjurmanovic/sugarr/internal/service"
"github.com/fjurmanovic/sugarr/internal/utl/configs"
)
// Init registers all routes on mux and wires up controllers.
func Init(mux *http.ServeMux, cfg *configs.Config, rec *service.Recommender) {
controller.NewHealthController(mux, cfg)
controller.NewRecommendationsController(mux, cfg, rec)
controller.NewRequestController(mux, cfg, rec)
}

View File

@@ -1,133 +0,0 @@
package client
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/fjurmanovic/sugarr/internal/model"
"github.com/fjurmanovic/sugarr/internal/utl/configs"
)
type JellyfinClient struct {
url string
apiKey string
userID string
http *http.Client
}
func NewJellyfinClient(cfg *configs.Config) *JellyfinClient {
return &JellyfinClient{
url: cfg.JellyfinURL,
apiKey: cfg.JellyfinAPIKey,
userID: cfg.JellyfinUserID,
http: &http.Client{},
}
}
type jellyfinItemsResponse struct {
Items []jellyfinItem `json:"Items"`
}
type jellyfinItem struct {
Name string `json:"Name"`
ProductionYear int `json:"ProductionYear"`
ProviderIds map[string]string `json:"ProviderIds"`
Genres []string `json:"Genres"`
CommunityRating float64 `json:"CommunityRating"`
UserData jellyfinUserData `json:"UserData"`
}
type jellyfinUserData struct {
IsFavorite bool `json:"IsFavorite"`
PlayCount int `json:"PlayCount"`
}
func (c *JellyfinClient) get(path string, params url.Values) (*jellyfinItemsResponse, error) {
u := fmt.Sprintf("%s%s?%s", c.url, path, params.Encode())
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("MediaBrowser Token=%q", c.apiKey))
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("jellyfin request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("jellyfin returned status %d for %s", resp.StatusCode, path)
}
var result jellyfinItemsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding jellyfin response: %w", err)
}
return &result, nil
}
func toMovies(items []jellyfinItem) []model.Movie {
movies := make([]model.Movie, 0, len(items))
for _, item := range items {
movies = append(movies, model.Movie{
Name: item.Name,
Year: item.ProductionYear,
IMDbID: item.ProviderIds["Imdb"],
TMDbID: item.ProviderIds["Tmdb"],
Genres: item.Genres,
CommunityRating: item.CommunityRating,
IsFavorite: item.UserData.IsFavorite,
PlayCount: item.UserData.PlayCount,
})
}
return movies
}
func (c *JellyfinClient) GetFavoriteMovies() ([]model.Movie, error) {
params := url.Values{
"IncludeItemTypes": {"Movie"},
"Recursive": {"true"},
"IsFavorite": {"true"},
"Fields": {"ProviderIds,Genres,CommunityRating,UserData"},
}
resp, err := c.get(fmt.Sprintf("/Users/%s/Items", c.userID), params)
if err != nil {
return nil, err
}
return toMovies(resp.Items), nil
}
func (c *JellyfinClient) GetRecentlyWatched() ([]model.Movie, error) {
params := url.Values{
"IncludeItemTypes": {"Movie"},
"Recursive": {"true"},
"Filters": {"IsPlayed"},
"SortBy": {"DatePlayed"},
"SortOrder": {"Descending"},
"Limit": {"50"},
"Fields": {"ProviderIds,Genres,CommunityRating,UserData"},
}
resp, err := c.get(fmt.Sprintf("/Users/%s/Items", c.userID), params)
if err != nil {
return nil, err
}
return toMovies(resp.Items), nil
}
func (c *JellyfinClient) GetAllMovies() ([]model.Movie, error) {
params := url.Values{
"IncludeItemTypes": {"Movie"},
"Recursive": {"true"},
"Fields": {"ProviderIds"},
"Limit": {"10000"},
}
resp, err := c.get(fmt.Sprintf("/Users/%s/Items", c.userID), params)
if err != nil {
return nil, err
}
return toMovies(resp.Items), nil
}

View File

@@ -1,164 +0,0 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/fjurmanovic/sugarr/internal/model"
"github.com/fjurmanovic/sugarr/internal/utl/configs"
)
type LLMClient struct {
baseURL string
apiKey string
model string
http *http.Client
}
func NewLLMClient(cfg *configs.Config) *LLMClient {
return &LLMClient{
baseURL: cfg.LLMBaseURL,
apiKey: cfg.LLMAPIKey,
model: cfg.LLMModel,
http: &http.Client{},
}
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatResponse struct {
Choices []struct {
Message chatMessage `json:"message"`
} `json:"choices"`
}
func (c *LLMClient) Recommend(prompt string) ([]model.MovieSuggestion, error) {
payload := chatRequest{
Model: c.model,
Messages: []chatMessage{{Role: "user", Content: prompt}},
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("llm request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var chatResp interface{}
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
return nil, fmt.Errorf("decoding llm response: %w", err)
}
return nil, fmt.Errorf("llm returned status %d with body: %s", resp.StatusCode, chatResp)
}
var chatResp chatResponse
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
return nil, fmt.Errorf("decoding llm response: %w", err)
}
if len(chatResp.Choices) == 0 {
return nil, fmt.Errorf("llm returned no choices")
}
return parseMovieSuggestions(chatResp.Choices[0].Message.Content)
}
// parseMovieSuggestions extracts a JSON array from LLM output,
// handling markdown code fences and surrounding prose.
func parseMovieSuggestions(content string) ([]model.MovieSuggestion, error) {
content = strings.TrimSpace(content)
// Strip markdown fences
if idx := strings.Index(content, "```"); idx != -1 {
content = content[idx:]
content = strings.TrimPrefix(content, "```json")
content = strings.TrimPrefix(content, "```")
if end := strings.Index(content, "```"); end != -1 {
content = content[:end]
}
content = strings.TrimSpace(content)
}
// Find JSON array boundaries
start := strings.Index(content, "[")
end := strings.LastIndex(content, "]")
if start == -1 || end == -1 || end <= start {
return nil, fmt.Errorf("no JSON array found in llm response: %s", content)
}
var suggestions []model.MovieSuggestion
if err := json.Unmarshal([]byte(content[start:end+1]), &suggestions); err != nil {
return nil, fmt.Errorf("parsing llm json: %w", err)
}
return suggestions, nil
}
// BuildPrompt constructs the recommendation prompt sent to the LLM.
// 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))
if len(favorites) == 0 {
sb.WriteString("(none)\n")
} else {
for _, m := range favorites {
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
}
}
sb.WriteString("\nUser's recently watched movies:\n")
if len(watched) == 0 {
sb.WriteString("(none)\n")
} else {
for _, m := range watched {
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
}
}
sb.WriteString("\nAlready in library (DO NOT suggest these):\n")
for _, m := range library {
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
}
if len(previouslySuggested) > 0 {
sb.WriteString("\nAlready suggested (DO NOT repeat these):\n")
for _, m := range previouslySuggested {
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
}
}
return sb.String()
}

View File

@@ -1,130 +0,0 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"github.com/fjurmanovic/sugarr/internal/utl/configs"
)
type RadarrMovie struct {
Title string `json:"title"`
TmdbId int `json:"tmdbId"`
QualityProfileId int `json:"qualityProfileId"`
RootFolderPath string `json:"rootFolderPath"`
Monitored bool `json:"monitored"`
AddOptions radarrAddOptions `json:"addOptions"`
}
type radarrAddOptions struct {
SearchForMovie bool `json:"searchForMovie"`
}
type RadarrClient struct {
url string
apiKey string
qualityProfile int
rootFolder string
http *http.Client
}
func NewRadarrClient(cfg *configs.Config) *RadarrClient {
return &RadarrClient{
url: cfg.RadarrURL,
apiKey: cfg.RadarrAPIKey,
qualityProfile: cfg.RadarrQualityProfile,
rootFolder: cfg.RadarrRootFolder,
http: &http.Client{},
}
}
func (c *RadarrClient) newRequest(method, path string, body []byte) (*http.Request, error) {
var req *http.Request
var err error
if body != nil {
req, err = http.NewRequest(method, c.url+path, bytes.NewReader(body))
} else {
req, err = http.NewRequest(method, c.url+path, nil)
}
if err != nil {
return nil, err
}
req.Header.Set("X-Api-Key", c.apiKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return req, nil
}
func (c *RadarrClient) Lookup(tmdbID int) (*RadarrMovie, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/v3/movie/lookup/tmdb?tmdbId=%d", tmdbID), nil)
if err != nil {
return nil, err
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("radarr lookup failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("radarr lookup returned status %d", resp.StatusCode)
}
var movie RadarrMovie
if err := json.NewDecoder(resp.Body).Decode(&movie); err != nil {
return nil, fmt.Errorf("decoding radarr lookup response: %w", err)
}
movie.QualityProfileId = c.qualityProfile
movie.RootFolderPath = c.rootFolder
movie.Monitored = true
movie.AddOptions = radarrAddOptions{SearchForMovie: true}
return &movie, nil
}
// AddMovie adds a movie to Radarr. Treats HTTP 400 (already exists) as success.
func (c *RadarrClient) AddMovie(movie RadarrMovie) error {
body, err := json.Marshal(movie)
if err != nil {
return err
}
req, err := c.newRequest(http.MethodPost, "/api/v3/movie", body)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("radarr add movie failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusBadRequest {
return fmt.Errorf("radarr add movie returned status %d", resp.StatusCode)
}
return nil
}
func (c *RadarrClient) GetExisting() ([]RadarrMovie, error) {
req, err := c.newRequest(http.MethodGet, "/api/v3/movie", nil)
if err != nil {
return nil, err
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("radarr get existing failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("radarr get existing returned status %d", resp.StatusCode)
}
var movies []RadarrMovie
if err := json.NewDecoder(resp.Body).Decode(&movies); err != nil {
return nil, fmt.Errorf("decoding radarr movies: %w", err)
}
return movies, nil
}

View File

@@ -1,111 +0,0 @@
package client
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"github.com/fjurmanovic/sugarr/internal/model"
"github.com/fjurmanovic/sugarr/internal/utl/configs"
)
const tmdbBaseURL = "https://api.themoviedb.org"
type TMDbClient struct {
apiKey string
http *http.Client
}
func NewTMDbClient(cfg *configs.Config) *TMDbClient {
return &TMDbClient{
apiKey: cfg.TMDbAPIKey,
http: &http.Client{},
}
}
type tmdbSearchResponse struct {
Results []tmdbMovieJSON `json:"results"`
}
type tmdbMovieJSON struct {
ID int `json:"id"`
Title string `json:"title"`
ReleaseDate string `json:"release_date"`
}
func (m tmdbMovieJSON) toModel() model.TMDbMovie {
year := 0
if len(m.ReleaseDate) >= 4 {
year, _ = strconv.Atoi(m.ReleaseDate[:4])
}
return model.TMDbMovie{ID: m.ID, Title: m.Title, Year: year}
}
func (c *TMDbClient) get(path string, params url.Values) (*http.Response, error) {
params.Set("api_key", c.apiKey)
u := fmt.Sprintf("%s%s?%s", tmdbBaseURL, path, params.Encode())
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("tmdb request failed: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("tmdb returned status %d for %s", resp.StatusCode, path)
}
return resp, nil
}
func (c *TMDbClient) SearchMovie(title string, year int) (*model.TMDbMovie, error) {
params := url.Values{"query": {title}}
if year > 0 {
params.Set("year", strconv.Itoa(year))
}
resp, err := c.get("/3/search/movie", params)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result tmdbSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding tmdb search response: %w", err)
}
if len(result.Results) == 0 {
return nil, nil
}
m := result.Results[0].toModel()
return &m, nil
}
func (c *TMDbClient) getMovieList(path string) ([]model.TMDbMovie, error) {
resp, err := c.get(path, url.Values{})
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result tmdbSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding tmdb response: %w", err)
}
movies := make([]model.TMDbMovie, 0, len(result.Results))
for _, r := range result.Results {
movies = append(movies, r.toModel())
}
return movies, nil
}
func (c *TMDbClient) GetSimilar(tmdbID int) ([]model.TMDbMovie, error) {
return c.getMovieList(fmt.Sprintf("/3/movie/%d/similar", tmdbID))
}
func (c *TMDbClient) GetRecommendations(tmdbID int) ([]model.TMDbMovie, error) {
return c.getMovieList(fmt.Sprintf("/3/movie/%d/recommendations", tmdbID))
}

View File

@@ -1,26 +0,0 @@
package controller
import (
"net/http"
"github.com/fjurmanovic/sugarr/internal/utl/configs"
"github.com/fjurmanovic/sugarr/internal/utl/httputil"
)
type HealthController struct {
cfg *configs.Config
}
func NewHealthController(mux *http.ServeMux, cfg *configs.Config) *HealthController {
c := &HealthController{cfg: cfg}
mux.HandleFunc("GET /health", c.handle)
return c
}
func (c *HealthController) handle(w http.ResponseWriter, _ *http.Request) {
httputil.WriteJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"llm": c.cfg.LLMEnabled,
"radarr": c.cfg.RadarrEnabled,
})
}

View File

@@ -1,127 +0,0 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"github.com/fjurmanovic/sugarr/internal/model"
"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"
)
type RecommendationsController struct {
cfg *configs.Config
rec *service.Recommender
}
func NewRecommendationsController(mux *http.ServeMux, cfg *configs.Config, rec *service.Recommender) *RecommendationsController {
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
}
// handleList runs the recommendation pipeline and returns results as JSON.
// Query params: ?count=10&force_llm=true&force_tmdb=true
func (c *RecommendationsController) handleList(w http.ResponseWriter, r *http.Request) {
count := c.cfg.RecommendCount
if v := r.URL.Query().Get("count"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
count = n
}
}
// Temporarily override LLM flag for force_llm / force_tmdb params
origLLM := c.cfg.LLMEnabled
if r.URL.Query().Get("force_llm") == "true" {
c.cfg.LLMEnabled = true
} else if r.URL.Query().Get("force_tmdb") == "true" {
c.cfg.LLMEnabled = false
}
recs, err := c.rec.RunWithCount(count)
c.cfg.LLMEnabled = origLLM
if err != nil {
log.Error().Err(err).Msg("Recommendation pipeline failed")
httputil.WriteError(w, http.StatusInternalServerError, err.Error())
return
}
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) {
if !c.cfg.LLMEnabled || c.rec.LLM() == nil {
httputil.WriteError(w, http.StatusBadRequest, "LLM is not enabled")
return
}
var body struct {
Query string `json:"query"`
}
if err := decodeJSON(r, &body); err != nil || body.Query == "" {
httputil.WriteError(w, http.StatusBadRequest, "body must be {\"query\": \"...\"}")
return
}
prompt := fmt.Sprintf(
"You are a movie recommendation engine. The user is looking for: %s\n\n"+
"Return ONLY a JSON array of %d movies matching this request. No other text.\n"+
"Each entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}",
body.Query, c.cfg.RecommendCount)
suggestions, err := c.rec.LLM().Recommend(prompt)
if err != nil {
log.Error().Err(err).Msg("LLM search failed")
httputil.WriteError(w, http.StatusInternalServerError, err.Error())
return
}
library, _ := c.rec.Jellyfin().GetAllMovies()
exclude := map[int]bool{}
for _, m := range library {
if id, err := strconv.Atoi(m.TMDbID); err == nil && id != 0 {
exclude[id] = true
}
}
var recs []model.Recommendation
for _, sug := range suggestions {
if len(recs) >= c.cfg.RecommendCount {
break
}
tm, err := c.rec.TMDb().SearchMovie(sug.Title, sug.Year)
if err != nil || tm == nil || exclude[tm.ID] {
continue
}
exclude[tm.ID] = true
recs = append(recs, model.Recommendation{
Title: tm.Title,
Year: tm.Year,
TMDbID: tm.ID,
Reason: sug.Reason,
})
}
httputil.WriteJSON(w, http.StatusOK, recs)
}

View File

@@ -1,59 +0,0 @@
package controller
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/fjurmanovic/sugarr/internal/service"
"github.com/fjurmanovic/sugarr/internal/utl/configs"
"github.com/fjurmanovic/sugarr/internal/utl/httputil"
)
type RequestController struct {
cfg *configs.Config
rec *service.Recommender
}
func NewRequestController(mux *http.ServeMux, cfg *configs.Config, rec *service.Recommender) *RequestController {
c := &RequestController{cfg: cfg, rec: rec}
mux.HandleFunc("POST /recommendations/request", c.handle)
return c
}
// handle adds a movie to Radarr by TMDb ID.
// Body: {"tmdb_id": 12345}
func (c *RequestController) handle(w http.ResponseWriter, r *http.Request) {
if !c.cfg.RadarrEnabled || c.rec.Radarr() == nil {
httputil.WriteError(w, http.StatusBadRequest, "Radarr is not enabled")
return
}
var body struct {
TMDbID int `json:"tmdb_id"`
}
if err := decodeJSON(r, &body); err != nil || body.TMDbID == 0 {
httputil.WriteError(w, http.StatusBadRequest, "body must be {\"tmdb_id\": 12345}")
return
}
movie, err := c.rec.Radarr().Lookup(body.TMDbID)
if err != nil {
httputil.WriteError(w, http.StatusInternalServerError, fmt.Sprintf("radarr lookup failed: %s", err))
return
}
if err := c.rec.Radarr().AddMovie(*movie); err != nil {
httputil.WriteError(w, http.StatusInternalServerError, fmt.Sprintf("radarr add failed: %s", err))
return
}
httputil.WriteJSON(w, http.StatusOK, map[string]any{"requested": true, "title": movie.Title})
}
func decodeJSON(r *http.Request, v any) error {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
return err
}
return json.Unmarshal(body, v)
}

View File

@@ -1,35 +0,0 @@
package model
// Movie represents a movie from the Jellyfin library.
type Movie struct {
Name string
Year int
IMDbID string
TMDbID string
Genres []string
CommunityRating float64
IsFavorite bool
PlayCount int
}
// TMDbMovie is a movie record returned by the TMDb API.
type TMDbMovie struct {
ID int
Title string
Year int
}
// MovieSuggestion is a raw suggestion from the LLM or TMDb fallback.
type MovieSuggestion struct {
Title string `json:"title"`
Year int `json:"year"`
Reason string `json:"reason"`
}
// Recommendation is a resolved movie recommendation ready to return to the user.
type Recommendation struct {
Title string `json:"title"`
Year int `json:"year"`
TMDbID int `json:"tmdb_id"`
Reason string `json:"reason"`
}

View File

@@ -1,231 +0,0 @@
package service
import (
"fmt"
"sort"
"strconv"
"github.com/fjurmanovic/sugarr/internal/client"
"github.com/fjurmanovic/sugarr/internal/model"
"github.com/fjurmanovic/sugarr/internal/utl/configs"
"github.com/rs/zerolog/log"
)
// Recommender orchestrates the full recommendation pipeline.
type Recommender struct {
cfg *configs.Config
jellyfin *client.JellyfinClient
tmdb *client.TMDbClient
llm *client.LLMClient // nil if LLM disabled
radarr *client.RadarrClient // nil if Radarr disabled
}
func NewRecommender(cfg *configs.Config) *Recommender {
r := &Recommender{
cfg: cfg,
jellyfin: client.NewJellyfinClient(cfg),
tmdb: client.NewTMDbClient(cfg),
}
if cfg.LLMEnabled {
r.llm = client.NewLLMClient(cfg)
}
if cfg.RadarrEnabled {
r.radarr = client.NewRadarrClient(cfg)
}
return r
}
func (r *Recommender) Run() ([]model.Recommendation, error) {
return r.RunWithCount(r.cfg.RecommendCount)
}
func (r *Recommender) RunWithCount(count int) ([]model.Recommendation, error) {
// 1. Determine signal movies
favorites, err := r.jellyfin.GetFavoriteMovies()
if err != nil {
return nil, fmt.Errorf("fetching favorites: %w", err)
}
var signalMovies, watchedMovies []model.Movie
if len(favorites) >= r.cfg.MinRatedMovies {
signalMovies = favorites
log.Info().Msgf("Using %d favorite movies as signal", len(favorites))
} else {
watchedMovies, err = r.jellyfin.GetRecentlyWatched()
if err != nil {
return nil, fmt.Errorf("fetching recently watched: %w", err)
}
signalMovies = watchedMovies
log.Info().Msgf("Not enough favorites (%d < %d), falling back to %d recently watched",
len(favorites), r.cfg.MinRatedMovies, len(watchedMovies))
}
// 2. Build exclusion set from Jellyfin library
library, err := r.jellyfin.GetAllMovies()
if err != nil {
log.Warn().Err(err).Msg("Could not fetch library; exclusion may be incomplete")
}
exclude := buildExclusionSet(library)
// Also exclude movies already in Radarr
if r.cfg.RadarrEnabled && r.radarr != nil {
if existing, err := r.radarr.GetExisting(); err != nil {
log.Warn().Err(err).Msg("Could not fetch Radarr library; exclusion may be incomplete")
} else {
for _, m := range existing {
exclude[m.TmdbId] = true
}
}
}
// 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
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)
}
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 len(recommendations) == before {
log.Warn().Msgf("No new recommendations found on attempt %d, stopping retries", attempt+1)
break
}
}
return recommendations, nil
}
// tmdbFallback gathers TMDb similar/recommended movies across signal movies,
// ranks by occurrence frequency, and returns top N suggestions.
func (r *Recommender) tmdbFallback(signals []model.Movie, exclude map[int]bool, count int) []model.MovieSuggestion {
freq := map[int]int{}
meta := map[int]model.TMDbMovie{}
for _, m := range signals {
tmdbID, err := strconv.Atoi(m.TMDbID)
if err != nil || tmdbID == 0 {
continue
}
for _, list := range [][]model.TMDbMovie{
mustGetList(r.tmdb.GetSimilar(tmdbID)),
mustGetList(r.tmdb.GetRecommendations(tmdbID)),
} {
for _, tm := range list {
if !exclude[tm.ID] {
freq[tm.ID]++
meta[tm.ID] = tm
}
}
}
}
type entry struct{ id, count int }
sorted := make([]entry, 0, len(freq))
for id, c := range freq {
sorted = append(sorted, entry{id, c})
}
sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count })
suggestions := make([]model.MovieSuggestion, 0, count)
for _, e := range sorted {
if len(suggestions) >= count {
break
}
tm := meta[e.id]
suggestions = append(suggestions, model.MovieSuggestion{
Title: tm.Title,
Year: tm.Year,
Reason: "Recommended by TMDb",
})
}
return suggestions
}
// AutoRequest adds all recommendations to Radarr.
func (r *Recommender) AutoRequest(recs []model.Recommendation) {
if r.radarr == nil {
return
}
for _, rec := range recs {
movie, err := r.radarr.Lookup(rec.TMDbID)
if err != nil {
log.Error().Err(err).Msgf("Radarr lookup failed for %s", rec.Title)
continue
}
if err := r.radarr.AddMovie(*movie); err != nil {
log.Error().Err(err).Msgf("Radarr add failed for %s", rec.Title)
continue
}
log.Info().Msgf("Requested in Radarr: %s (%d)", rec.Title, rec.Year)
}
}
// LLM returns the LLM client, or nil if not enabled.
func (r *Recommender) LLM() *client.LLMClient { return r.llm }
// Radarr returns the Radarr client, or nil if not enabled.
func (r *Recommender) Radarr() *client.RadarrClient { return r.radarr }
// Jellyfin returns the Jellyfin client.
func (r *Recommender) Jellyfin() *client.JellyfinClient { return r.jellyfin }
// TMDb returns the TMDb client.
func (r *Recommender) TMDb() *client.TMDbClient { return r.tmdb }
func buildExclusionSet(movies []model.Movie) map[int]bool {
exclude := map[int]bool{}
for _, m := range movies {
if id, err := strconv.Atoi(m.TMDbID); err == nil && id != 0 {
exclude[id] = true
}
}
return exclude
}
func mustGetList(movies []model.TMDbMovie, _ error) []model.TMDbMovie {
if movies == nil {
return nil
}
return movies
}

View File

@@ -1,45 +0,0 @@
package configs
import (
"github.com/joho/godotenv"
"github.com/kelseyhightower/envconfig"
)
type Config struct {
JellyfinURL string `envconfig:"JELLYFIN_URL" required:"true"`
JellyfinAPIKey string `envconfig:"JELLYFIN_API_KEY" required:"true"`
JellyfinUserID string `envconfig:"JELLYFIN_USER_ID" required:"true"`
TMDbAPIKey string `envconfig:"TMDB_API_KEY" required:"true"`
LLMEnabled bool `envconfig:"LLM_ENABLED" default:"false"`
LLMBaseURL string `envconfig:"LLM_BASE_URL" default:"http://localhost:11434/v1"`
LLMAPIKey string `envconfig:"LLM_API_KEY" default:""`
LLMModel string `envconfig:"LLM_MODEL" default:"llama3"`
RadarrEnabled bool `envconfig:"RADARR_ENABLED" default:"false"`
RadarrURL string `envconfig:"RADARR_URL"`
RadarrAPIKey string `envconfig:"RADARR_API_KEY"`
RadarrQualityProfile int `envconfig:"RADARR_QUALITY_PROFILE_ID" default:"1"`
RadarrRootFolder string `envconfig:"RADARR_ROOT_FOLDER" default:"/movies"`
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"`
APISecretKey string `envconfig:"SECRET_KEY" default:""`
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.
func Load() (*Config, error) {
_ = godotenv.Load()
var cfg Config
if err := envconfig.Process("", &cfg); err != nil {
return nil, err
}
return &cfg, nil
}

View File

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

View File

@@ -1,25 +0,0 @@
package server
import (
"fmt"
"net/http"
"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"
)
// Start registers all routes and begins listening. Blocks until the server exits.
func Start(cfg *configs.Config, rec *service.Recommender) error {
mux := http.NewServeMux()
api.Init(mux, cfg, rec)
addr := fmt.Sprintf(":%d", cfg.APIPort)
log.Info().Msgf("HTTP API listening on %s", addr)
if cfg.APISecretKey != "" {
log.Info().Msg("Secret key auth enabled")
}
return http.ListenAndServe(addr, httputil.AuthMiddleware(cfg.APISecretKey, mux))
}

View 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"]
}

View 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"]
}

40
package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "sugarr",
"version": "0.1.0",
"private": true,
"main": "dist/app/index.js",
"packageManager": "pnpm@11.21.0",
"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": {
"@langfuse/client": "^5.10.0",
"@langfuse/otel": "^5.10.0",
"@langfuse/tracing": "^5.10.0",
"@opentelemetry/sdk-node": "^0.221.0",
"dotenv": "^16.4.5",
"fastify": "^5.0.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"
}
}

3682
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
allowBuilds:
protobufjs: true

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 }, { trigger: 'api-list' });
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 },
{ trigger: 'api-trigger' }
);
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, 'api-search');
reply.send(recommendations);
} catch (err) {
reply.code(500).send({ error: String(err) });
}
}
}

View File

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

View File

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

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

@@ -0,0 +1,26 @@
import cron from 'node-cron';
import type { Logger } from '../core/logging/logger';
import type { AppConfig } from './config/AppConfig';
import type { RecommendationPipeline } from './pipelines/RecommendationPipeline';
export function scheduleCron(config: AppConfig, pipeline: RecommendationPipeline, logger: Logger): void {
if (!config.CRON_ENABLED) return;
cron.schedule(config.CRON_SCHEDULE, async () => {
logger.info('Cron: running recommendation pipeline');
try {
const recommendations = await pipeline.runAsync(
{ count: config.RECOMMEND_COUNT, useLlm: config.LLM_ENABLED },
{ trigger: 'cron' }
);
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');
}

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

@@ -0,0 +1,102 @@
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,
langfuse: config.LANGFUSE_BASE_URL,
},
'sugarr starting'
);
scheduleCron(config, pipeline, logger);
const shutdown = () => {
tracer.shutdown().catch((err) => logger.error({ err }, 'Error shutting down Langfuse tracer'));
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
if (config.API_ENABLED) {
const app = buildServer({
secretKey: config.SECRET_KEY,
controllers: [
new HealthController(config),
new RecommendationsController(config, pipeline),
new RequestController(config, radarr),
new WebhookController(promptService),
],
});
await app.listen({ port: config.API_PORT, host: '0.0.0.0' });
logger.info({ port: config.API_PORT }, 'HTTP API listening');
}
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});

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

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

View File

@@ -0,0 +1,216 @@
import { randomUUID } from 'node:crypto';
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, trigger?: string): Promise<Recommendation[]> {
const sessionId = randomUUID();
return this.tracer.runWithSession(
{ sessionId, tags: trigger ? [trigger] : undefined },
() => this.runSearch(query, count, trigger)
);
}
private async runSearch(query: string, count: number, trigger?: string): Promise<Recommendation[]> {
const trace = this.tracer.startTrace(`${this.name}-search`, { trigger });
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,
};
}

View File

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

View File

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

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

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

View File

@@ -0,0 +1,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
View File

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

View File

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

View File

@@ -0,0 +1,97 @@
import { NodeSDK } from '@opentelemetry/sdk-node';
import { LangfuseSpanProcessor } from '@langfuse/otel';
import { propagateAttributes, startObservation } from '@langfuse/tracing';
import type { LangfuseGeneration, LangfuseSpan } from '@langfuse/tracing';
import { Tracer } from '../llm/Tracer';
import type {
GenerationHandle,
GenerationInput,
GenerationOutput,
SessionInfo,
TraceHandle,
} from '../llm/Tracer';
interface OpenAiUsageDetails {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
prompt_tokens_details?: { cached_tokens: number };
}
/**
* Langfuse recognizes usage in either a flat generic schema (input/output/
* cache_read_input_tokens/...) or OpenAI's native nested schema
* (prompt_tokens/completion_tokens/prompt_tokens_details.cached_tokens/...)
* - but NOT a mix of the two ("do not mix formats" per Langfuse's docs).
* Mixing them previously caused the whole generation.update() payload
* (including output) to land wrong, so this stays entirely within the
* OpenAI-native schema.
*
* The installed @langfuse/tracing SDK's usageDetails type is
* `Record<string, number> | OpenAiUsage` (a 3-field camelCase shape with
* no _details nesting) even though the wire schema (@langfuse/core's
* OpenAiCompletionUsageSchema) supports this nested form - a gap in the
* SDK's public types, not a real runtime restriction, hence the cast.
*/
function toUsageDetails(usage: GenerationOutput['usage']): OpenAiUsageDetails | undefined {
if (!usage) return undefined;
const details: OpenAiUsageDetails = {};
if (usage.promptTokens !== undefined) details.prompt_tokens = usage.promptTokens;
if (usage.completionTokens !== undefined) details.completion_tokens = usage.completionTokens;
if (usage.totalTokens !== undefined) details.total_tokens = usage.totalTokens;
if (usage.cachedTokens !== undefined) details.prompt_tokens_details = { cached_tokens: usage.cachedTokens };
return Object.keys(details).length > 0 ? details : undefined;
}
export class LangfuseTracer extends Tracer {
private readonly processor: LangfuseSpanProcessor;
private readonly sdk: NodeSDK;
constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) {
super();
this.processor = new LangfuseSpanProcessor({
publicKey: opts.publicKey,
secretKey: opts.secretKey,
baseUrl: opts.baseUrl,
});
this.sdk = new NodeSDK({ spanProcessors: [this.processor] });
this.sdk.start();
}
startTrace(name: string, metadata?: Record<string, unknown>): TraceHandle {
const span: LangfuseSpan = startObservation(name, { metadata });
return {
id: span.traceId,
startGeneration(input: GenerationInput): GenerationHandle {
const generation: LangfuseGeneration = span.startObservation(
input.name,
{ model: input.model, input: input.input, metadata: input.metadata },
{ asType: 'generation' }
);
return {
end(output: GenerationOutput) {
generation.update({
output: output.output,
usageDetails: toUsageDetails(output.usage) as Record<string, number> | undefined,
costDetails: output.usage?.cost !== undefined ? { total: output.usage.cost } : undefined,
metadata: output.metadata,
});
generation.end();
},
};
},
};
}
async flush(): Promise<void> {
await this.processor.forceFlush();
}
runWithSession<T>(session: SessionInfo, fn: () => Promise<T>): Promise<T> {
return propagateAttributes({ sessionId: session.sessionId, tags: session.tags }, fn);
}
async shutdown(): Promise<void> {
await this.sdk.shutdown();
}
}

View File

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

View File

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

View File

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

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

@@ -0,0 +1,76 @@
import OpenAI from 'openai';
import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions';
import type { CompletionUsage } from 'openai/resources/completions';
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;
cachedTokens?: number;
cost?: number;
};
}
/**
* OpenRouter-specific request/response extensions, not part of OpenAI's
* official schema. `usage.include` asks OpenRouter to report its own real
* per-request cost (requires Usage Accounting enabled on the OpenRouter
* account); other OpenAI-compatible providers generally ignore unknown
* top-level request fields. See:
* https://langfuse.com/integrations/gateways/openrouter
*/
type OpenRouterChatCompletionCreateParams = ChatCompletionCreateParamsNonStreaming & {
usage?: { include: boolean };
};
type OpenRouterCompletionUsage = CompletionUsage & { cost?: 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,
usage: { include: true },
} as OpenRouterChatCompletionCreateParams);
const choice = response.choices[0];
if (!choice) {
throw new Error('LLM returned no choices');
}
const usage = response.usage as OpenRouterCompletionUsage | undefined;
return {
content: choice.message?.content ?? '',
usage: usage
? {
promptTokens: usage.prompt_tokens,
completionTokens: usage.completion_tokens,
totalTokens: usage.total_tokens,
cachedTokens: usage.prompt_tokens_details?.cached_tokens,
cost: usage.cost,
}
: undefined,
};
}
}

View File

@@ -0,0 +1,44 @@
import { randomUUID } from 'node:crypto';
import type { Logger } from '../logging/logger';
import type { LlmTool } from './LlmTool';
import type { RunContext } from './RunContext';
import type { Tracer } from './Tracer';
export interface PipelineRunOptions {
/** What triggered this run (e.g. "cron", "api-list") — attached as a Langfuse tag. */
trigger?: string;
}
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, options: PipelineRunOptions = {}): Promise<TOutput> {
const sessionId = randomUUID();
return this.tracer.runWithSession(
{ sessionId, tags: options.trigger ? [options.trigger] : undefined },
async () => {
const trace = this.tracer.startTrace(this.name, { trigger: options.trigger });
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
View 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;
}
}

View File

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

View File

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

View File

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

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

@@ -0,0 +1,43 @@
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;
cachedTokens?: number;
cost?: number;
};
metadata?: Record<string, unknown>;
}
export interface GenerationHandle {
end(output: GenerationOutput): void;
}
export interface TraceHandle {
readonly id: string;
startGeneration(input: GenerationInput): GenerationHandle;
}
export interface SessionInfo {
sessionId: string;
tags?: string[];
}
export abstract class Tracer {
abstract startTrace(name: string, metadata?: Record<string, unknown>): TraceHandle;
abstract flush(): Promise<void>;
/**
* Runs `fn` with `session` (sessionId/tags) attached to every trace/span
* created inside it, so multiple runs can be grouped in the Langfuse UI.
*/
abstract runWithSession<T>(session: SessionInfo, fn: () => Promise<T>): Promise<T>;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 }, { trigger: 'api-list' });
});
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 }, { trigger: 'api-list' });
});
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 }, { trigger: 'api-list' });
await app.inject({ method: 'GET', url: '/recommendations?count=-5' });
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true }, { trigger: 'api-list' });
await app.inject({ method: 'GET', url: '/recommendations?count=0' });
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true }, { trigger: 'api-list' });
});
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, 'api-search');
});
});

View File

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

View File

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

View File

@@ -0,0 +1,430 @@
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),
runWithSession: vi.fn().mockImplementation((_session: unknown, fn: () => unknown) => fn()),
} 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'
);
});
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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');
});
});

View File

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

View File

@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from 'vitest';
const {
forceFlush,
shutdown,
sdkStart,
sdkShutdown,
generationUpdate,
generationEnd,
childStartObservation,
startObservation,
propagateAttributes,
} = vi.hoisted(() => {
const generationUpdate = vi.fn();
const generationEnd = vi.fn();
const generation = { update: generationUpdate, end: generationEnd };
const childStartObservation = vi.fn().mockReturnValue(generation);
const rootSpan = { traceId: 'lf-trace-1', startObservation: childStartObservation };
return {
forceFlush: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
sdkStart: vi.fn(),
sdkShutdown: vi.fn().mockResolvedValue(undefined),
generationUpdate,
generationEnd,
childStartObservation,
startObservation: vi.fn().mockReturnValue(rootSpan),
propagateAttributes: vi.fn().mockImplementation((_attrs: unknown, fn: () => unknown) => fn()),
};
});
vi.mock('@langfuse/otel', () => ({
LangfuseSpanProcessor: vi.fn().mockImplementation(() => ({ forceFlush, shutdown })),
}));
vi.mock('@opentelemetry/sdk-node', () => ({
NodeSDK: vi.fn().mockImplementation(() => ({ start: sdkStart, shutdown: sdkShutdown })),
}));
vi.mock('@langfuse/tracing', () => ({ startObservation, propagateAttributes }));
import { LangfuseTracer } from '../../../src/core/langfuse/LangfuseTracer';
describe('LangfuseTracer', () => {
it('starts the OTEL SDK with a LangfuseSpanProcessor on construction', () => {
new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
expect(sdkStart).toHaveBeenCalled();
});
it('starts a root observation and exposes its trace id', () => {
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
const handle = tracer.startTrace('my-pipeline', { foo: 'bar' });
expect(startObservation).toHaveBeenCalledWith('my-pipeline', { metadata: { foo: 'bar' } });
expect(handle.id).toBe('lf-trace-1');
});
it('startGeneration creates a child generation observation 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: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
});
expect(childStartObservation).toHaveBeenCalledWith(
'step-1',
expect.objectContaining({ model: 'llama3', input: ['hi'] }),
{ asType: 'generation' }
);
expect(generationUpdate).toHaveBeenCalledWith(
expect.objectContaining({
output: 'done',
usageDetails: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
costDetails: undefined,
})
);
expect(generationEnd).toHaveBeenCalledTimes(1);
});
it('forwards an explicit cost (e.g. from OpenRouter) as costDetails.total instead of relying on price-list lookup', () => {
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: 'openrouter/auto', input: ['hi'] });
gen.end({
output: 'done',
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cost: 0.00042 },
});
expect(generationUpdate).toHaveBeenCalledWith(
expect.objectContaining({ costDetails: { total: 0.00042 } })
);
});
it('maps cachedTokens to the OpenAI-native nested prompt_tokens_details.cached_tokens, not a mixed flat key', () => {
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: 'gpt-4o-mini', input: ['hi'] });
gen.end({
output: 'done',
usage: { promptTokens: 100, completionTokens: 20, totalTokens: 120, cachedTokens: 80 },
});
expect(generationUpdate).toHaveBeenCalledWith(
expect.objectContaining({
usageDetails: {
prompt_tokens: 100,
completion_tokens: 20,
total_tokens: 120,
prompt_tokens_details: { cached_tokens: 80 },
},
})
);
});
it('omits usageDetails entirely when no usage is reported', () => {
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' });
expect(generationUpdate).toHaveBeenCalledWith(expect.objectContaining({ usageDetails: undefined }));
});
it('flush() force-flushes the span processor without shutting down the SDK', async () => {
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
await tracer.flush();
expect(forceFlush).toHaveBeenCalledTimes(1);
expect(sdkShutdown).not.toHaveBeenCalled();
});
it('shutdown() shuts down the underlying OTEL SDK', async () => {
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
await tracer.shutdown();
expect(sdkShutdown).toHaveBeenCalledTimes(1);
});
it('runWithSession propagates sessionId/tags to the callback via propagateAttributes and returns its result', async () => {
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
const fn = vi.fn().mockResolvedValue('done');
const result = await tracer.runWithSession({ sessionId: 'session-1', tags: ['cron'] }, fn);
expect(propagateAttributes).toHaveBeenCalledWith({ sessionId: 'session-1', tags: ['cron'] }, fn);
expect(result).toBe('done');
});
});

View File

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

View File

@@ -0,0 +1,83 @@
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' }],
usage: { include: true },
});
});
it('extracts OpenRouter cost from the response usage when present', async () => {
createMock.mockResolvedValue({
choices: [{ message: { content: 'hello' } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, cost: 0.00042 },
});
const client = new LlmClient({ baseUrl: 'https://openrouter.ai/api/v1', apiKey: 'key' });
const result = await client.chat([{ role: 'user', content: 'hi' }], { model: 'openrouter/auto' });
expect(result.usage).toEqual({
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
cost: 0.00042,
});
});
it('extracts cachedTokens from prompt_tokens_details when present', async () => {
createMock.mockResolvedValue({
choices: [{ message: { content: 'hello' } }],
usage: {
prompt_tokens: 100,
completion_tokens: 20,
total_tokens: 120,
prompt_tokens_details: { cached_tokens: 80 },
},
});
const client = new LlmClient({ baseUrl: 'http://localhost:11434/v1' });
const result = await client.chat([{ role: 'user', content: 'hi' }], { model: 'gpt-4o-mini' });
expect(result.usage).toEqual({
promptTokens: 100,
completionTokens: 20,
totalTokens: 120,
cachedTokens: 80,
});
});
it('throws when the response has no choices', async () => {
createMock.mockResolvedValue({ choices: [] });
const client = new LlmClient({ baseUrl: 'http://localhost:11434/v1' });
await expect(client.chat([{ role: 'user', content: 'hi' }], { model: 'llama3' })).rejects.toThrow(
/no choices/
);
});
});

View File

@@ -0,0 +1,94 @@
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 runWithSession = vi.fn().mockImplementation((_session: unknown, fn: () => unknown) => fn());
const tracer = { startTrace, flush, runWithSession } as unknown as Tracer;
return { tracer, startTrace, flush, runWithSession };
}
describe('LlmPipeline', () => {
it('runs the whole call inside a session scoped to a fresh session id, opens one trace, runs runInternal, and flushes afterward', async () => {
const trace = { id: 'trace-1', startGeneration: vi.fn() } as unknown as TraceHandle;
const { tracer, startTrace, flush, runWithSession } = 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(runWithSession).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: expect.any(String), tags: undefined }),
expect.any(Function)
);
expect(startTrace).toHaveBeenCalledWith('sum-pipeline', { trigger: undefined });
expect(flush).toHaveBeenCalledTimes(1);
});
it('tags the session with the given trigger', async () => {
const trace = { id: 'trace-1', startGeneration: vi.fn() } as unknown as TraceHandle;
const { tracer, startTrace, runWithSession } = makeTracer(trace);
const logger = {} as unknown as Logger;
const pipeline = new SumPipeline(tracer, logger);
await pipeline.runAsync([1, 2, 3], { trigger: 'cron' });
expect(runWithSession).toHaveBeenCalledWith(
expect.objectContaining({ tags: ['cron'] }),
expect.any(Function)
);
expect(startTrace).toHaveBeenCalledWith('sum-pipeline', { trigger: 'cron' });
});
it('still flushes the trace when runInternal throws', async () => {
const trace = { id: 'trace-2', startGeneration: vi.fn() } as unknown as TraceHandle;
const { tracer, flush } = makeTracer(trace);
const logger = {} as unknown as Logger;
const pipeline = new ThrowingPipeline(tracer, logger);
await expect(pipeline.runAsync()).rejects.toThrow('pipeline failed');
expect(flush).toHaveBeenCalledTimes(1);
});
it('runTool delegates to the given tool.runAsync with the same context', async () => {
const trace = { id: 'trace-3', startGeneration: vi.fn() } as unknown as TraceHandle;
const { tracer } = makeTracer(trace);
const logger = {} as unknown as Logger;
class ToolRunnerPipeline extends LlmPipeline<void, string> {
protected readonly name = 'tool-runner';
protected async runInternal(_input: void, ctx: RunContext): Promise<string> {
const tool = { runAsync: vi.fn().mockResolvedValue('tool-output') } as unknown as LlmTool<
Record<string, never>,
string
>;
return this.runTool(tool, {}, ctx);
}
}
const pipeline = new ToolRunnerPipeline(tracer, logger);
const result = await pipeline.runAsync();
expect(result).toBe('tool-output');
});
});

View File

@@ -0,0 +1,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 })
);
});
});

View File

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

View File

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

View File

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

View File

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

17
tsconfig.json Normal file
View File

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

8
vitest.config.ts Normal file
View File

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