mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-11 15:51:09 +00:00
init
This commit is contained in:
30
.env
Normal file
30
.env
Normal file
@@ -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
|
||||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -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"]
|
||||||
102
cmd/sugarr/main.go
Normal file
102
cmd/sugarr/main.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal file
@@ -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
|
||||||
16
go.mod
Normal file
16
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
22
go.sum
Normal file
22
go.sum
Normal file
@@ -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=
|
||||||
16
internal/api/api.go
Normal file
16
internal/api/api.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
133
internal/client/jellyfin.go
Normal file
133
internal/client/jellyfin.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
150
internal/client/llm.go
Normal file
150
internal/client/llm.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
130
internal/client/radarr.go
Normal file
130
internal/client/radarr.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
111
internal/client/tmdb.go
Normal file
111
internal/client/tmdb.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
26
internal/controller/health.go
Normal file
26
internal/controller/health.go
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
111
internal/controller/recommendations.go
Normal file
111
internal/controller/recommendations.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
59
internal/controller/request.go
Normal file
59
internal/controller/request.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
35
internal/model/model.go
Normal file
35
internal/model/model.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
215
internal/service/recommender.go
Normal file
215
internal/service/recommender.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
43
internal/utl/configs/config.go
Normal file
43
internal/utl/configs/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
16
internal/utl/httputil/httputil.go
Normal file
16
internal/utl/httputil/httputil.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
21
internal/utl/server/server.go
Normal file
21
internal/utl/server/server.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user