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

View 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,
})
}

View 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)
}

View 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)
}