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