Files
sugarr/internal/controller/recommendations.go
2026-04-22 18:23:19 +02:00

112 lines
3.3 KiB
Go

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