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:
2026-08-12 21:45:32 +02:00
parent ae8bfaa2eb
commit 83bb16e2a0
22 changed files with 35 additions and 1400 deletions

35
.gitignore vendored
View File

@@ -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

View File

@@ -1,11 +1,17 @@
FROM golang:1.24-alpine AS build
FROM node:22-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /sugarr ./cmd/sugarr
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
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"]

View File

@@ -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"]

View File

@@ -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`

View File

@@ -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()
}

View File

@@ -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:

View File

@@ -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

16
go.mod
View File

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

22
go.sum
View File

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

View File

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

View File

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

View File

@@ -1,164 +0,0 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/fjurmanovic/sugarr/internal/model"
"github.com/fjurmanovic/sugarr/internal/utl/configs"
)
type LLMClient struct {
baseURL string
apiKey string
model string
http *http.Client
}
func NewLLMClient(cfg *configs.Config) *LLMClient {
return &LLMClient{
baseURL: cfg.LLMBaseURL,
apiKey: cfg.LLMAPIKey,
model: cfg.LLMModel,
http: &http.Client{},
}
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatResponse struct {
Choices []struct {
Message chatMessage `json:"message"`
} `json:"choices"`
}
func (c *LLMClient) Recommend(prompt string) ([]model.MovieSuggestion, error) {
payload := chatRequest{
Model: c.model,
Messages: []chatMessage{{Role: "user", Content: prompt}},
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("llm request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var chatResp interface{}
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
return nil, fmt.Errorf("decoding llm response: %w", err)
}
return nil, fmt.Errorf("llm returned status %d with body: %s", resp.StatusCode, chatResp)
}
var chatResp chatResponse
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
return nil, fmt.Errorf("decoding llm response: %w", err)
}
if len(chatResp.Choices) == 0 {
return nil, fmt.Errorf("llm returned no choices")
}
return parseMovieSuggestions(chatResp.Choices[0].Message.Content)
}
// parseMovieSuggestions extracts a JSON array from LLM output,
// handling markdown code fences and surrounding prose.
func parseMovieSuggestions(content string) ([]model.MovieSuggestion, error) {
content = strings.TrimSpace(content)
// Strip markdown fences
if idx := strings.Index(content, "```"); idx != -1 {
content = content[idx:]
content = strings.TrimPrefix(content, "```json")
content = strings.TrimPrefix(content, "```")
if end := strings.Index(content, "```"); end != -1 {
content = content[:end]
}
content = strings.TrimSpace(content)
}
// Find JSON array boundaries
start := strings.Index(content, "[")
end := strings.LastIndex(content, "]")
if start == -1 || end == -1 || end <= start {
return nil, fmt.Errorf("no JSON array found in llm response: %s", content)
}
var suggestions []model.MovieSuggestion
if err := json.Unmarshal([]byte(content[start:end+1]), &suggestions); err != nil {
return nil, fmt.Errorf("parsing llm json: %w", err)
}
return suggestions, nil
}
// BuildPrompt constructs the recommendation prompt sent to the LLM.
// previouslySuggested contains movies returned in prior retry attempts that must not be repeated.
func BuildPrompt(favorites, watched, library, previouslySuggested []model.Movie, count int) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf(
"You are a movie recommendation engine. Based on the user's favorite and recently watched movies below, suggest exactly %d movies they would enjoy.\n\n"+
"IMPORTANT:\n"+
"- Do NOT suggest any movie from the \"Already In Library\" list.\n"+
"- Do NOT suggest any movie from the \"Already Suggested\" list.\n"+
"- Return ONLY a JSON array, no other text.\n"+
"- Each entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}\n\n"+
"User's favorite movies:\n", count))
if len(favorites) == 0 {
sb.WriteString("(none)\n")
} else {
for _, m := range favorites {
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
}
}
sb.WriteString("\nUser's recently watched movies:\n")
if len(watched) == 0 {
sb.WriteString("(none)\n")
} else {
for _, m := range watched {
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
}
}
sb.WriteString("\nAlready in library (DO NOT suggest these):\n")
for _, m := range library {
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
}
if len(previouslySuggested) > 0 {
sb.WriteString("\nAlready suggested (DO NOT repeat these):\n")
for _, m := range previouslySuggested {
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
}
}
return sb.String()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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
}

View File

@@ -1,45 +0,0 @@
package configs
import (
"github.com/joho/godotenv"
"github.com/kelseyhightower/envconfig"
)
type Config struct {
JellyfinURL string `envconfig:"JELLYFIN_URL" required:"true"`
JellyfinAPIKey string `envconfig:"JELLYFIN_API_KEY" required:"true"`
JellyfinUserID string `envconfig:"JELLYFIN_USER_ID" required:"true"`
TMDbAPIKey string `envconfig:"TMDB_API_KEY" required:"true"`
LLMEnabled bool `envconfig:"LLM_ENABLED" default:"false"`
LLMBaseURL string `envconfig:"LLM_BASE_URL" default:"http://localhost:11434/v1"`
LLMAPIKey string `envconfig:"LLM_API_KEY" default:""`
LLMModel string `envconfig:"LLM_MODEL" default:"llama3"`
RadarrEnabled bool `envconfig:"RADARR_ENABLED" default:"false"`
RadarrURL string `envconfig:"RADARR_URL"`
RadarrAPIKey string `envconfig:"RADARR_API_KEY"`
RadarrQualityProfile int `envconfig:"RADARR_QUALITY_PROFILE_ID" default:"1"`
RadarrRootFolder string `envconfig:"RADARR_ROOT_FOLDER" default:"/movies"`
CronEnabled bool `envconfig:"CRON_ENABLED" default:"false"`
CronSchedule string `envconfig:"CRON_SCHEDULE" default:"0 3 * * *"`
APIEnabled bool `envconfig:"API_ENABLED" default:"true"`
APIPort int `envconfig:"API_PORT" default:"8080"`
APISecretKey string `envconfig:"SECRET_KEY" default:""`
RecommendCount int `envconfig:"RECOMMEND_COUNT" default:"10"`
RecommendMaxRetry int `envconfig:"RECOMMEND_MAX_RETRY" default:"3"`
MinRatedMovies int `envconfig:"MIN_RATED_MOVIES" default:"5"`
}
// Load reads .env (best-effort) then processes environment variables into a Config.
func Load() (*Config, error) {
_ = godotenv.Load()
var cfg Config
if err := envconfig.Process("", &cfg); err != nil {
return nil, err
}
return &cfg, nil
}

View File

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

View File

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