From e3e85e4a44d05ec806023c85c5ce52e4410d33b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Wed, 22 Apr 2026 18:23:19 +0200 Subject: [PATCH] init --- .env | 30 ++++ Dockerfile | 11 ++ cmd/sugarr/main.go | 102 ++++++++++++ docker-compose.yml | 26 +++ go.mod | 16 ++ go.sum | 22 +++ internal/api/api.go | 16 ++ internal/client/jellyfin.go | 133 +++++++++++++++ internal/client/llm.go | 150 +++++++++++++++++ internal/client/radarr.go | 130 +++++++++++++++ internal/client/tmdb.go | 111 +++++++++++++ internal/controller/health.go | 26 +++ internal/controller/recommendations.go | 111 +++++++++++++ internal/controller/request.go | 59 +++++++ internal/model/model.go | 35 ++++ internal/service/recommender.go | 215 +++++++++++++++++++++++++ internal/utl/configs/config.go | 43 +++++ internal/utl/httputil/httputil.go | 16 ++ internal/utl/server/server.go | 21 +++ 19 files changed, 1273 insertions(+) create mode 100644 .env create mode 100644 Dockerfile create mode 100644 cmd/sugarr/main.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/api.go create mode 100644 internal/client/jellyfin.go create mode 100644 internal/client/llm.go create mode 100644 internal/client/radarr.go create mode 100644 internal/client/tmdb.go create mode 100644 internal/controller/health.go create mode 100644 internal/controller/recommendations.go create mode 100644 internal/controller/request.go create mode 100644 internal/model/model.go create mode 100644 internal/service/recommender.go create mode 100644 internal/utl/configs/config.go create mode 100644 internal/utl/httputil/httputil.go create mode 100644 internal/utl/server/server.go diff --git a/.env b/.env new file mode 100644 index 0000000..80fc4c3 --- /dev/null +++ b/.env @@ -0,0 +1,30 @@ +# Required +JELLYFIN_URL=http://localhost:8096 +JELLYFIN_API_KEY= +JELLYFIN_USER_ID= +TMDB_API_KEY= + +# LLM (disabled by default — works with Ollama, OpenAI, LM Studio, etc.) +LLM_ENABLED=false +LLM_BASE_URL=http://localhost:11434/v1 +LLM_API_KEY= +LLM_MODEL=gpt-oss-4080 + +# Radarr (disabled by default) +RADARR_ENABLED=false +RADARR_URL=http://localhost:7878 +RADARR_API_KEY= +RADARR_QUALITY_PROFILE_ID=1 +RADARR_ROOT_FOLDER=/movies + +# Cron auto-run (disabled by default) +CRON_ENABLED=false +CRON_SCHEDULE=0 3 * * * + +# HTTP API (enabled by default) +API_ENABLED=true +API_PORT=8080 + +# Tuning +RECOMMEND_COUNT=10 +MIN_RATED_MOVIES=5 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1bf4324 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.24-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 + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +COPY --from=build /sugarr /usr/local/bin/ +ENTRYPOINT ["sugarr"] diff --git a/cmd/sugarr/main.go b/cmd/sugarr/main.go new file mode 100644 index 0000000..a4d1519 --- /dev/null +++ b/cmd/sugarr/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/fjurmanovic/sugarr/internal/model" + "github.com/fjurmanovic/sugarr/internal/service" + "github.com/fjurmanovic/sugarr/internal/utl/configs" + "github.com/fjurmanovic/sugarr/internal/utl/server" + "github.com/robfig/cron/v3" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func main() { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + + cfg, err := configs.Load() + if err != nil { + log.Fatal().Err(err).Msg("Failed to load config") + } + + printBanner(cfg) + + rec := service.NewRecommender(cfg) + + // Cron scheduler + if cfg.CronEnabled { + c := cron.New() + if _, err := c.AddFunc(cfg.CronSchedule, func() { + log.Info().Msg("Cron: running recommendation pipeline") + recs, err := rec.Run() + if err != nil { + log.Error().Err(err).Msg("Cron: pipeline failed") + return + } + if cfg.RadarrEnabled { + rec.AutoRequest(recs) + } + logRecommendations(recs) + }); err != nil { + log.Fatal().Err(err).Msg("Invalid cron schedule") + } + c.Start() + defer c.Stop() + log.Info().Msgf("Cron scheduled: %s", cfg.CronSchedule) + } + + // HTTP API — blocks until server exits + if cfg.APIEnabled { + if err := server.Start(cfg, rec); err != nil { + log.Fatal().Err(err).Msg("HTTP server failed") + } + return + } + + // One-shot mode: no API, no cron + if !cfg.CronEnabled { + recs, err := rec.Run() + if err != nil { + log.Fatal().Err(err).Msg("Recommendation pipeline failed") + } + printTable(recs) + return + } + + // Cron-only mode: block forever + select {} +} + +func printBanner(cfg *configs.Config) { + log.Info().Msg("sugarr starting") + log.Info().Msgf(" Jellyfin: %s (user: %s)", cfg.JellyfinURL, cfg.JellyfinUserID) + log.Info().Msgf(" LLM: enabled=%v model=%s", cfg.LLMEnabled, cfg.LLMModel) + log.Info().Msgf(" Radarr: enabled=%v", cfg.RadarrEnabled) + log.Info().Msgf(" Cron: enabled=%v schedule=%s", cfg.CronEnabled, cfg.CronSchedule) + log.Info().Msgf(" API: enabled=%v port=%d", cfg.APIEnabled, cfg.APIPort) + log.Info().Msgf(" Count: %d recommendations", cfg.RecommendCount) +} + +func logRecommendations(recs []model.Recommendation) { + log.Info().Msgf("Recommendations (%d):", len(recs)) + for i, r := range recs { + log.Info().Msgf(" %d. %s (%d) [tmdb:%d] — %s", i+1, r.Title, r.Year, r.TMDbID, r.Reason) + } +} + +func printTable(recs []model.Recommendation) { + if len(recs) == 0 { + fmt.Println("No recommendations found.") + return + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "#\tTitle\tYear\tTMDb ID\tReason") + fmt.Fprintln(w, "-\t-----\t----\t-------\t------") + for i, r := range recs { + fmt.Fprintf(w, "%d\t%s\t%d\t%d\t%s\n", i+1, r.Title, r.Year, r.TMDbID, r.Reason) + } + w.Flush() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f00d286 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + sugarr: + build: . + container_name: sugarr + restart: unless-stopped + ports: + - "8080:8080" + environment: + JELLYFIN_URL: http://jellyfin:8096 + JELLYFIN_API_KEY: ${JELLYFIN_API_KEY} + JELLYFIN_USER_ID: ${JELLYFIN_USER_ID} + TMDB_API_KEY: ${TMDB_API_KEY} + # LLM_ENABLED: "true" + # LLM_BASE_URL: http://ollama:11434/v1 + # LLM_MODEL: llama3 + # RADARR_ENABLED: "true" + # RADARR_URL: http://radarr:7878 + # RADARR_API_KEY: ${RADARR_API_KEY} + # CRON_ENABLED: "true" + # CRON_SCHEDULE: "0 3 * * *" + networks: + - media + +networks: + media: + external: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3a28af0 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..4d7c2ec --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..7892807 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..e72fd2b --- /dev/null +++ b/internal/client/jellyfin.go @@ -0,0 +1,133 @@ +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 new file mode 100644 index 0000000..34fdc53 --- /dev/null +++ b/internal/client/llm.go @@ -0,0 +1,150 @@ +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 { + return nil, fmt.Errorf("llm returned status %d", resp.StatusCode) + } + + 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. +func BuildPrompt(favorites, watched, library []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"+ + "- 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)) + } + return sb.String() +} diff --git a/internal/client/radarr.go b/internal/client/radarr.go new file mode 100644 index 0000000..22aa870 --- /dev/null +++ b/internal/client/radarr.go @@ -0,0 +1,130 @@ +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 new file mode 100644 index 0000000..dc790b8 --- /dev/null +++ b/internal/client/tmdb.go @@ -0,0 +1,111 @@ +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 new file mode 100644 index 0000000..41d6512 --- /dev/null +++ b/internal/controller/health.go @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..35d925b --- /dev/null +++ b/internal/controller/recommendations.go @@ -0,0 +1,111 @@ +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) + 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) +} + +// 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 new file mode 100644 index 0000000..887c9e0 --- /dev/null +++ b/internal/controller/request.go @@ -0,0 +1,59 @@ +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 new file mode 100644 index 0000000..1427161 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..d1005e5 --- /dev/null +++ b/internal/service/recommender.go @@ -0,0 +1,215 @@ +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. Get raw suggestions + var suggestions []model.MovieSuggestion + if r.cfg.LLMEnabled && r.llm != nil { + prompt := client.BuildPrompt(favorites, watchedMovies, library, count) + suggestions, err = r.llm.Recommend(prompt) + if err != nil { + return nil, fmt.Errorf("llm recommendation failed: %w", err) + } + log.Info().Msgf("LLM returned %d suggestions", len(suggestions)) + } else { + suggestions = r.tmdbFallback(signalMovies, exclude, count) + log.Info().Msgf("TMDb fallback returned %d suggestions", len(suggestions)) + } + + // 4. Resolve suggestions via TMDb and apply exclusions + var recommendations []model.Recommendation + 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 + } + 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, + }) + } + + 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 new file mode 100644 index 0000000..3b03584 --- /dev/null +++ b/internal/utl/configs/config.go @@ -0,0 +1,43 @@ +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"` + + RecommendCount int `envconfig:"RECOMMEND_COUNT" default:"10"` + 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 new file mode 100644 index 0000000..199a1d4 --- /dev/null +++ b/internal/utl/httputil/httputil.go @@ -0,0 +1,16 @@ +package httputil + +import ( + "encoding/json" + "net/http" +) + +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}) +} diff --git a/internal/utl/server/server.go b/internal/utl/server/server.go new file mode 100644 index 0000000..ff11419 --- /dev/null +++ b/internal/utl/server/server.go @@ -0,0 +1,21 @@ +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/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) + return http.ListenAndServe(addr, mux) +}