This commit is contained in:
2026-04-22 18:23:19 +02:00
commit e3e85e4a44
19 changed files with 1273 additions and 0 deletions

133
internal/client/jellyfin.go Normal file
View 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
View 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
View 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
View 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))
}