From 83bb16e2a06ea9a39baa51fa1dd77f2fdee63d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Wed, 12 Aug 2026 21:45:32 +0200 Subject: [PATCH] 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. --- .gitignore | 35 +--- Dockerfile | 24 ++- Dockerfile.node | 17 -- README.md | 22 ++- cmd/sugarr/main.go | 105 ----------- docker-compose.node.yml | 40 ----- docker-compose.yml | 13 +- go.mod | 16 -- go.sum | 22 --- internal/api/api.go | 16 -- internal/client/jellyfin.go | 133 -------------- internal/client/llm.go | 164 ----------------- internal/client/radarr.go | 130 -------------- internal/client/tmdb.go | 111 ------------ internal/controller/health.go | 26 --- internal/controller/recommendations.go | 127 ------------- internal/controller/request.go | 59 ------- internal/model/model.go | 35 ---- internal/service/recommender.go | 235 ------------------------- internal/utl/configs/config.go | 45 ----- internal/utl/httputil/httputil.go | 35 ---- internal/utl/server/server.go | 25 --- 22 files changed, 35 insertions(+), 1400 deletions(-) delete mode 100644 Dockerfile.node delete mode 100644 cmd/sugarr/main.go delete mode 100644 docker-compose.node.yml delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/api/api.go delete mode 100644 internal/client/jellyfin.go delete mode 100644 internal/client/llm.go delete mode 100644 internal/client/radarr.go delete mode 100644 internal/client/tmdb.go delete mode 100644 internal/controller/health.go delete mode 100644 internal/controller/recommendations.go delete mode 100644 internal/controller/request.go delete mode 100644 internal/model/model.go delete mode 100644 internal/service/recommender.go delete mode 100644 internal/utl/configs/config.go delete mode 100644 internal/utl/httputil/httputil.go delete mode 100644 internal/utl/server/server.go diff --git a/.gitignore b/.gitignore index 7851333..f0ca217 100644 --- a/.gitignore +++ b/.gitignore @@ -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 -# Architecture specific extensions/prefixes -*.[568vq] -[568vq].out - -*.cgo1.go -*.cgo2.c -_cgo_defun.c -_cgo_gotypes.go -_cgo_export.* - -_testmain.go - -*.exe -*.test -*.prof -*.log -*.db - -# .Dockerfile - .zed deploy.config @@ -41,3 +7,4 @@ deploy.config node_modules/ dist/ coverage/ +*.log diff --git a/Dockerfile b/Dockerfile index 1bf4324..3ea0847 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,17 @@ -FROM golang:1.24-alpine AS build +FROM node:22-alpine AS build WORKDIR /app -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -o /sugarr ./cmd/sugarr +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --ignore-scripts +COPY tsconfig.json ./ +COPY src ./src +RUN pnpm build -FROM alpine:3.19 -RUN apk add --no-cache ca-certificates -COPY --from=build /sugarr /usr/local/bin/ -ENTRYPOINT ["sugarr"] +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod +COPY --from=build /app/dist ./dist +ENTRYPOINT ["node", "dist/app/index.js"] diff --git a/Dockerfile.node b/Dockerfile.node deleted file mode 100644 index 3ea0847..0000000 --- a/Dockerfile.node +++ /dev/null @@ -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"] diff --git a/README.md b/README.md index 7a45686..5ecd9b5 100644 --- a/README.md +++ b/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. 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. -- **`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 +## Running the app ```bash pnpm install @@ -36,12 +37,9 @@ See `.env.example` for the full list with defaults. At minimum you need: ### Docker ```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 The app fetches two chat prompts from Langfuse by name — `movie-recommender` diff --git a/cmd/sugarr/main.go b/cmd/sugarr/main.go deleted file mode 100644 index 24cdb6a..0000000 --- a/cmd/sugarr/main.go +++ /dev/null @@ -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() -} diff --git a/docker-compose.node.yml b/docker-compose.node.yml deleted file mode 100644 index 400770f..0000000 --- a/docker-compose.node.yml +++ /dev/null @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 15527da..2eaca74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: sugarr: - build: . + build: + context: . + dockerfile: Dockerfile container_name: sugarr restart: unless-stopped ports: @@ -23,11 +25,14 @@ services: CRON_SCHEDULE: ${CRON_SCHEDULE} API_ENABLED: ${API_ENABLED} API_PORT: ${API_PORT} - RECOMMEND_COUNT: ${RECOMMEND_COUNT} - MIN_RATED_MOVIES: ${MIN_RATED_MOVIES} 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 diff --git a/go.mod b/go.mod deleted file mode 100644 index 3a28af0..0000000 --- a/go.mod +++ /dev/null @@ -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 -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 4d7c2ec..0000000 --- a/go.sum +++ /dev/null @@ -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= diff --git a/internal/api/api.go b/internal/api/api.go deleted file mode 100644 index 7892807..0000000 --- a/internal/api/api.go +++ /dev/null @@ -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) -} diff --git a/internal/client/jellyfin.go b/internal/client/jellyfin.go deleted file mode 100644 index e72fd2b..0000000 --- a/internal/client/jellyfin.go +++ /dev/null @@ -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 -} diff --git a/internal/client/llm.go b/internal/client/llm.go deleted file mode 100644 index c53fc59..0000000 --- a/internal/client/llm.go +++ /dev/null @@ -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() -} diff --git a/internal/client/radarr.go b/internal/client/radarr.go deleted file mode 100644 index 22aa870..0000000 --- a/internal/client/radarr.go +++ /dev/null @@ -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 -} diff --git a/internal/client/tmdb.go b/internal/client/tmdb.go deleted file mode 100644 index dc790b8..0000000 --- a/internal/client/tmdb.go +++ /dev/null @@ -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)) -} diff --git a/internal/controller/health.go b/internal/controller/health.go deleted file mode 100644 index 41d6512..0000000 --- a/internal/controller/health.go +++ /dev/null @@ -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, - }) -} diff --git a/internal/controller/recommendations.go b/internal/controller/recommendations.go deleted file mode 100644 index 8230a82..0000000 --- a/internal/controller/recommendations.go +++ /dev/null @@ -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) -} diff --git a/internal/controller/request.go b/internal/controller/request.go deleted file mode 100644 index 887c9e0..0000000 --- a/internal/controller/request.go +++ /dev/null @@ -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) -} diff --git a/internal/model/model.go b/internal/model/model.go deleted file mode 100644 index 1427161..0000000 --- a/internal/model/model.go +++ /dev/null @@ -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"` -} diff --git a/internal/service/recommender.go b/internal/service/recommender.go deleted file mode 100644 index 6461494..0000000 --- a/internal/service/recommender.go +++ /dev/null @@ -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 -} diff --git a/internal/utl/configs/config.go b/internal/utl/configs/config.go deleted file mode 100644 index 3345c14..0000000 --- a/internal/utl/configs/config.go +++ /dev/null @@ -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 -} diff --git a/internal/utl/httputil/httputil.go b/internal/utl/httputil/httputil.go deleted file mode 100644 index 7e1e321..0000000 --- a/internal/utl/httputil/httputil.go +++ /dev/null @@ -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 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) - }) -} diff --git a/internal/utl/server/server.go b/internal/utl/server/server.go deleted file mode 100644 index 45e954d..0000000 --- a/internal/utl/server/server.go +++ /dev/null @@ -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)) -}