mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-11 15:51:09 +00:00
Compare commits
3 Commits
0.1.0
...
a90f9dc19b
| Author | SHA1 | Date | |
|---|---|---|---|
| a90f9dc19b | |||
| 83bb16e2a0 | |||
| ae8bfaa2eb |
35
.gitignore
vendored
35
.gitignore
vendored
@@ -1,39 +1,5 @@
|
|||||||
# ---> Go
|
|
||||||
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
|
||||||
*.o
|
|
||||||
*.a
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# Folders
|
|
||||||
_obj
|
|
||||||
_test
|
|
||||||
vendor
|
|
||||||
bin
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
|
|
||||||
.env
|
.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
|
.zed
|
||||||
|
|
||||||
deploy.config
|
deploy.config
|
||||||
@@ -41,3 +7,4 @@ deploy.config
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
coverage/
|
coverage/
|
||||||
|
*.log
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@@ -1,11 +1,17 @@
|
|||||||
FROM golang:1.24-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
RUN corepack enable
|
||||||
RUN go mod download
|
COPY package.json pnpm-lock.yaml ./
|
||||||
COPY . .
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /sugarr ./cmd/sugarr
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
FROM alpine:3.19
|
FROM node:22-alpine AS runtime
|
||||||
RUN apk add --no-cache ca-certificates
|
WORKDIR /app
|
||||||
COPY --from=build /sugarr /usr/local/bin/
|
ENV NODE_ENV=production
|
||||||
ENTRYPOINT ["sugarr"]
|
RUN corepack enable
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
ENTRYPOINT ["node", "dist/app/index.js"]
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
FROM node:22-alpine AS build
|
|
||||||
WORKDIR /app
|
|
||||||
RUN corepack enable
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
|
||||||
COPY tsconfig.json ./
|
|
||||||
COPY src ./src
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
FROM node:22-alpine AS runtime
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
RUN corepack enable
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
|
||||||
COPY --from=build /app/dist ./dist
|
|
||||||
ENTRYPOINT ["node", "dist/app/index.js"]
|
|
||||||
92
README.md
Normal file
92
README.md
Normal 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>`.
|
||||||
@@ -1,105 +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"
|
|
||||||
)
|
|
||||||
|
|
||||||
const version = "0.0.1"
|
|
||||||
|
|
||||||
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(" Version: %s", version)
|
|
||||||
log.Info().Msgf(" Jellyfin: %s (user: %s)", cfg.JellyfinURL, cfg.JellyfinUserID)
|
|
||||||
log.Info().Msgf(" LLM: enabled=%v model=%s", cfg.LLMEnabled, cfg.LLMModel)
|
|
||||||
log.Info().Msgf(" Radarr: enabled=%v", cfg.RadarrEnabled)
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
services:
|
|
||||||
sugarr-node:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.node
|
|
||||||
container_name: sugarr-node
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "${API_PORT}:${API_PORT}"
|
|
||||||
environment:
|
|
||||||
JELLYFIN_URL: ${JELLYFIN_URL}
|
|
||||||
JELLYFIN_API_KEY: ${JELLYFIN_API_KEY}
|
|
||||||
JELLYFIN_USER_ID: ${JELLYFIN_USER_ID}
|
|
||||||
TMDB_API_KEY: ${TMDB_API_KEY}
|
|
||||||
LLM_ENABLED: ${LLM_ENABLED}
|
|
||||||
LLM_BASE_URL: ${LLM_BASE_URL}
|
|
||||||
LLM_MODEL: ${LLM_MODEL}
|
|
||||||
LLM_API_KEY: ${LLM_API_KEY}
|
|
||||||
RADARR_ENABLED: ${RADARR_ENABLED}
|
|
||||||
RADARR_URL: ${RADARR_URL}
|
|
||||||
RADARR_API_KEY: ${RADARR_API_KEY}
|
|
||||||
RADARR_QUALITY_PROFILE_ID: ${RADARR_QUALITY_PROFILE_ID}
|
|
||||||
RADARR_ROOT_FOLDER: ${RADARR_ROOT_FOLDER}
|
|
||||||
CRON_ENABLED: ${CRON_ENABLED}
|
|
||||||
CRON_SCHEDULE: ${CRON_SCHEDULE}
|
|
||||||
API_ENABLED: ${API_ENABLED}
|
|
||||||
API_PORT: ${API_PORT}
|
|
||||||
SECRET_KEY: ${SECRET_KEY}
|
|
||||||
RECOMMEND_COUNT: ${RECOMMEND_COUNT}
|
|
||||||
RECOMMEND_MAX_RETRY: ${RECOMMEND_MAX_RETRY}
|
|
||||||
MIN_RATED_MOVIES: ${MIN_RATED_MOVIES}
|
|
||||||
LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY}
|
|
||||||
LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY}
|
|
||||||
LANGFUSE_BASE_URL: ${LANGFUSE_BASE_URL}
|
|
||||||
LANGFUSE_PROMPT_CACHE_ENABLED: ${LANGFUSE_PROMPT_CACHE_ENABLED}
|
|
||||||
networks:
|
|
||||||
- media
|
|
||||||
|
|
||||||
networks:
|
|
||||||
media:
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
sugarr:
|
sugarr:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
container_name: sugarr
|
container_name: sugarr
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT}:${API_PORT}"
|
- '${API_PORT}:${API_PORT}'
|
||||||
environment:
|
environment:
|
||||||
JELLYFIN_URL: ${JELLYFIN_URL}
|
JELLYFIN_URL: ${JELLYFIN_URL}
|
||||||
JELLYFIN_API_KEY: ${JELLYFIN_API_KEY}
|
JELLYFIN_API_KEY: ${JELLYFIN_API_KEY}
|
||||||
@@ -23,13 +25,18 @@ services:
|
|||||||
CRON_SCHEDULE: ${CRON_SCHEDULE}
|
CRON_SCHEDULE: ${CRON_SCHEDULE}
|
||||||
API_ENABLED: ${API_ENABLED}
|
API_ENABLED: ${API_ENABLED}
|
||||||
API_PORT: ${API_PORT}
|
API_PORT: ${API_PORT}
|
||||||
RECOMMEND_COUNT: ${RECOMMEND_COUNT}
|
|
||||||
MIN_RATED_MOVIES: ${MIN_RATED_MOVIES}
|
|
||||||
SECRET_KEY: ${SECRET_KEY}
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
|
RECOMMEND_COUNT: ${RECOMMEND_COUNT}
|
||||||
RECOMMEND_MAX_RETRY: ${RECOMMEND_MAX_RETRY}
|
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:
|
networks:
|
||||||
- media
|
- media
|
||||||
|
- coolify
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
media:
|
media:
|
||||||
|
coolify:
|
||||||
|
|||||||
16
go.mod
16
go.mod
@@ -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
22
go.sum
@@ -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=
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -1,235 +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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(suggestions) == 0 {
|
|
||||||
log.Warn().Msgf("No suggestions returned on attempt %d, stopping retries", attempt+1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
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.Info().Msgf("All suggestions on attempt %d were already in library, retrying", attempt+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
20
langfuse/movie-recommender.prompt.json
Normal file
20
langfuse/movie-recommender.prompt.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "movie-recommender",
|
||||||
|
"type": "chat",
|
||||||
|
"prompt": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "You are a movie recommendation engine. Based on the user's favorite and recently watched movies below, suggest exactly {{count}} movies they would enjoy.\n\nIMPORTANT:\n- Do NOT suggest any movie from the \"Already In Library\" list.\n- Do NOT suggest any movie from the \"Already Suggested\" list.\n- Return ONLY a JSON array, no other text.\n- Each entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "User's favorite movies:\n{{favorites}}\n\nUser's recently watched movies:\n{{watched}}\n\nAlready in library (DO NOT suggest these):\n{{library}}\n\nAlready suggested (DO NOT repeat these):\n{{previouslySuggested}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"model": "llama3",
|
||||||
|
"temperature": 0.7,
|
||||||
|
"maxTokens": 1024
|
||||||
|
},
|
||||||
|
"labels": ["production"]
|
||||||
|
}
|
||||||
20
langfuse/movie-search.prompt.json
Normal file
20
langfuse/movie-search.prompt.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "movie-search",
|
||||||
|
"type": "chat",
|
||||||
|
"prompt": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "You are a movie recommendation engine. Return ONLY a JSON array of {{count}} movies matching the user's request. No other text.\nEach entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "The user is looking for: {{query}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"model": "llama3",
|
||||||
|
"temperature": 0.7,
|
||||||
|
"maxTokens": 1024
|
||||||
|
},
|
||||||
|
"labels": ["production"]
|
||||||
|
}
|
||||||
@@ -30,7 +30,8 @@ export class RecommendationsController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async handleList(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
private async handleList(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
||||||
const count = request.query.count ? Number(request.query.count) : this.config.RECOMMEND_COUNT;
|
const parsedCount = request.query.count ? Number(request.query.count) : NaN;
|
||||||
|
const count = Number.isInteger(parsedCount) && parsedCount > 0 ? parsedCount : this.config.RECOMMEND_COUNT;
|
||||||
const useLlm =
|
const useLlm =
|
||||||
request.query.force_tmdb === 'true'
|
request.query.force_tmdb === 'true'
|
||||||
? false
|
? false
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ async function main(): Promise<void> {
|
|||||||
? new RadarrClient(config.RADARR_URL, config.RADARR_API_KEY, config.RADARR_QUALITY_PROFILE_ID, config.RADARR_ROOT_FOLDER)
|
? new RadarrClient(config.RADARR_URL, config.RADARR_API_KEY, config.RADARR_QUALITY_PROFILE_ID, config.RADARR_ROOT_FOLDER)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const recommenderTool = new MovieRecommenderTool(promptService, llmClient);
|
const recommenderTool = new MovieRecommenderTool(promptService, llmClient, config.LLM_MODEL);
|
||||||
const searchTool = new MovieSearchTool(promptService, llmClient);
|
const searchTool = new MovieSearchTool(promptService, llmClient, config.LLM_MODEL);
|
||||||
|
|
||||||
const pipeline = new RecommendationPipeline(tracer, logger, {
|
const pipeline = new RecommendationPipeline(tracer, logger, {
|
||||||
jellyfin,
|
jellyfin,
|
||||||
|
|||||||
@@ -41,12 +41,21 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
|
|||||||
const watched = useFavoritesAsSignal ? [] : await this.options.jellyfin.getRecentlyWatched();
|
const watched = useFavoritesAsSignal ? [] : await this.options.jellyfin.getRecentlyWatched();
|
||||||
const signal = useFavoritesAsSignal ? favorites : watched;
|
const signal = useFavoritesAsSignal ? favorites : watched;
|
||||||
|
|
||||||
const library = await this.options.jellyfin.getAllMovies();
|
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 exclude = new Set<number>(toTmdbIds(library));
|
||||||
|
|
||||||
if (this.options.radarr) {
|
if (this.options.radarr) {
|
||||||
const existing = await this.options.radarr.getExisting();
|
try {
|
||||||
for (const m of existing) exclude.add(m.tmdbId);
|
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 recommendations: Recommendation[] = [];
|
||||||
@@ -70,7 +79,13 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
|
|||||||
|
|
||||||
for (const suggestion of suggestions) {
|
for (const suggestion of suggestions) {
|
||||||
if (recommendations.length >= input.count) break;
|
if (recommendations.length >= input.count) break;
|
||||||
const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year);
|
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;
|
if (!resolved) continue;
|
||||||
seen.push(toSeenMovie(resolved));
|
seen.push(toSeenMovie(resolved));
|
||||||
if (exclude.has(resolved.id)) continue;
|
if (exclude.has(resolved.id)) continue;
|
||||||
@@ -103,14 +118,25 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
|
|||||||
const trace = this.tracer.startTrace(`${this.name}-search`);
|
const trace = this.tracer.startTrace(`${this.name}-search`);
|
||||||
const ctx: RunContext = { trace, logger: this.logger };
|
const ctx: RunContext = { trace, logger: this.logger };
|
||||||
try {
|
try {
|
||||||
const library = await this.options.jellyfin.getAllMovies();
|
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 exclude = new Set<number>(toTmdbIds(library));
|
||||||
const suggestions = await this.runTool(this.options.searchTool, { query, count }, ctx);
|
const suggestions = await this.runTool(this.options.searchTool, { query, count }, ctx);
|
||||||
|
|
||||||
const recommendations: Recommendation[] = [];
|
const recommendations: Recommendation[] = [];
|
||||||
for (const suggestion of suggestions) {
|
for (const suggestion of suggestions) {
|
||||||
if (recommendations.length >= count) break;
|
if (recommendations.length >= count) break;
|
||||||
const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year);
|
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;
|
if (!resolved || exclude.has(resolved.id)) continue;
|
||||||
exclude.add(resolved.id);
|
exclude.add(resolved.id);
|
||||||
recommendations.push({
|
recommendations.push({
|
||||||
@@ -133,10 +159,17 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
|
|||||||
for (const m of signals) {
|
for (const m of signals) {
|
||||||
const tmdbId = Number(m.tmdbId);
|
const tmdbId = Number(m.tmdbId);
|
||||||
if (!tmdbId) continue;
|
if (!tmdbId) continue;
|
||||||
const [similar, recommended] = await Promise.all([
|
let similar: TMDbMovie[] = [];
|
||||||
this.options.tmdb.getSimilar(tmdbId),
|
let recommended: TMDbMovie[] = [];
|
||||||
this.options.tmdb.getRecommendations(tmdbId),
|
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]) {
|
for (const tm of [...similar, ...recommended]) {
|
||||||
if (!exclude.has(tm.id)) {
|
if (!exclude.has(tm.id)) {
|
||||||
freq.set(tm.id, (freq.get(tm.id) ?? 0) + 1);
|
freq.set(tm.id, (freq.get(tm.id) ?? 0) + 1);
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export abstract class ApiClient {
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const ok = res.ok || endpoint.acceptableStatuses.includes(res.status);
|
const ok = res.ok || endpoint.acceptableStatuses.includes(res.status);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new ApiError(`${endpoint.method} ${url} returned ${res.status}`, res.status, text);
|
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;
|
const json = text.length > 0 ? JSON.parse(text) : undefined;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export abstract class LlmTool<TInput, TOutput> {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly promptService: PromptService,
|
protected readonly promptService: PromptService,
|
||||||
protected readonly llmClient: LlmClient
|
protected readonly llmClient: LlmClient,
|
||||||
|
protected readonly defaultModel: string = 'gpt-4o-mini'
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
protected abstract buildVariables(input: TInput, ctx: RunContext): Record<string, unknown>;
|
protected abstract buildVariables(input: TInput, ctx: RunContext): Record<string, unknown>;
|
||||||
@@ -27,7 +28,7 @@ export abstract class LlmTool<TInput, TOutput> {
|
|||||||
const variables = this.buildVariables(processedInput, ctx);
|
const variables = this.buildVariables(processedInput, ctx);
|
||||||
const compiled = await this.promptService.getCompiled(this.promptName, variables);
|
const compiled = await this.promptService.getCompiled(this.promptName, variables);
|
||||||
|
|
||||||
const model = String(compiled.config.model ?? 'gpt-4o-mini');
|
const model = String(compiled.config.model ?? this.defaultModel);
|
||||||
const temperature =
|
const temperature =
|
||||||
typeof compiled.config.temperature === 'number' ? compiled.config.temperature : undefined;
|
typeof compiled.config.temperature === 'number' ? compiled.config.temperature : undefined;
|
||||||
const maxTokens =
|
const maxTokens =
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ describe('RecommendationsController', () => {
|
|||||||
expect(runAsync).toHaveBeenCalledWith({ count: 3, useLlm: true });
|
expect(runAsync).toHaveBeenCalledWith({ count: 3, useLlm: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to the configured count for a malformed or non-positive count query param', async () => {
|
||||||
|
const runAsync = vi.fn().mockResolvedValue([]);
|
||||||
|
const app = makeApp({ RECOMMEND_COUNT: 10, LLM_ENABLED: true }, { runAsync } as unknown as RecommendationPipeline);
|
||||||
|
|
||||||
|
await app.inject({ method: 'GET', url: '/recommendations?count=abc' });
|
||||||
|
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
|
||||||
|
|
||||||
|
await app.inject({ method: 'GET', url: '/recommendations?count=-5' });
|
||||||
|
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
|
||||||
|
|
||||||
|
await app.inject({ method: 'GET', url: '/recommendations?count=0' });
|
||||||
|
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /recommendations/trigger auto-requests via Radarr when enabled', async () => {
|
it('POST /recommendations/trigger auto-requests via Radarr when enabled', async () => {
|
||||||
const recs = [{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }];
|
const recs = [{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }];
|
||||||
const runAsync = vi.fn().mockResolvedValue(recs);
|
const runAsync = vi.fn().mockResolvedValue(recs);
|
||||||
|
|||||||
@@ -171,6 +171,119 @@ describe('RecommendationPipeline.runAsync', () => {
|
|||||||
expect(tool.runAsync).not.toHaveBeenCalled();
|
expect(tool.runAsync).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual([{ title: 'Similar A', year: 2010, tmdbId: 20, reason: 'Recommended by TMDb' }]);
|
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', () => {
|
describe('RecommendationPipeline.autoRequest', () => {
|
||||||
@@ -283,4 +396,34 @@ describe('RecommendationPipeline.searchAsync', () => {
|
|||||||
expect(result).toEqual([{ title: 'New Find', year: 2020, tmdbId: 6, reason: 'matches query' }]);
|
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());
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,4 +101,11 @@ describe('ApiClient', () => {
|
|||||||
const client = new TestClient('https://api.example.com');
|
const client = new TestClient('https://api.example.com');
|
||||||
await expect(client.getThing(1)).rejects.toBeInstanceOf(ApiError);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,6 +60,44 @@ describe('LlmTool.runAsync', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it('ends the generation with error metadata and rethrows when the LLM call fails', async () => {
|
||||||
const promptService = {
|
const promptService = {
|
||||||
getCompiled: vi.fn().mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [] }),
|
getCompiled: vi.fn().mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [] }),
|
||||||
|
|||||||
Reference in New Issue
Block a user