mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-10 23:31:08 +00:00
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.
This commit is contained in:
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"]
|
|
||||||
22
README.md
22
README.md
@@ -6,15 +6,16 @@ similarity/frequency fallback, resolves them against TMDb, excludes anything
|
|||||||
already in your library or Radarr, and can auto-request the rest via Radarr.
|
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.
|
Runs on a cron schedule and/or as an HTTP API.
|
||||||
|
|
||||||
This repo currently has two implementations:
|
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).
|
||||||
|
|
||||||
- **`cmd/`, `internal/`** — the original Go implementation.
|
## Running the app
|
||||||
- **`src/`** — a TypeScript/OOP rewrite (Node + Fastify + Langfuse), the one
|
|
||||||
documented below. See `docs/superpowers/specs/2026-08-12-typescript-oop-rewrite-design.md`
|
|
||||||
and `docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md` for the
|
|
||||||
design and implementation plan.
|
|
||||||
|
|
||||||
## Running the TypeScript app
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
@@ -36,12 +37,9 @@ See `.env.example` for the full list with defaults. At minimum you need:
|
|||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.node.yml up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Uses `Dockerfile.node` / `docker-compose.node.yml`, kept separate from the Go
|
|
||||||
app's `Dockerfile` / `docker-compose.yml`.
|
|
||||||
|
|
||||||
## Seeding Langfuse prompts
|
## Seeding Langfuse prompts
|
||||||
|
|
||||||
The app fetches two chat prompts from Langfuse by name — `movie-recommender`
|
The app fetches two chat prompts from Langfuse by name — `movie-recommender`
|
||||||
|
|||||||
@@ -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,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
sugarr:
|
sugarr:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
container_name: sugarr
|
container_name: sugarr
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -23,11 +25,14 @@ 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
|
||||||
|
|
||||||
|
|||||||
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))
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user