mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-11 15:51:09 +00:00
init
This commit is contained in:
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