diff --git a/internal/client/llm.go b/internal/client/llm.go index b78f8d6..c53fc59 100644 --- a/internal/client/llm.go +++ b/internal/client/llm.go @@ -119,12 +119,14 @@ func parseMovieSuggestions(content string) ([]model.MovieSuggestion, error) { } // BuildPrompt constructs the recommendation prompt sent to the LLM. -func BuildPrompt(favorites, watched, library []model.Movie, count int) string { +// previouslySuggested contains movies returned in prior retry attempts that must not be repeated. +func BuildPrompt(favorites, watched, library, previouslySuggested []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"+ + "- Do NOT suggest any movie from the \"Already Suggested\" 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)) @@ -150,5 +152,13 @@ func BuildPrompt(favorites, watched, library []model.Movie, count int) string { for _, m := range library { sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year)) } + + if len(previouslySuggested) > 0 { + sb.WriteString("\nAlready suggested (DO NOT repeat these):\n") + for _, m := range previouslySuggested { + sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year)) + } + } + return sb.String() } diff --git a/internal/controller/recommendations.go b/internal/controller/recommendations.go index 35d925b..8230a82 100644 --- a/internal/controller/recommendations.go +++ b/internal/controller/recommendations.go @@ -21,6 +21,7 @@ func NewRecommendationsController(mux *http.ServeMux, cfg *configs.Config, rec * c := &RecommendationsController{cfg: cfg, rec: rec} mux.HandleFunc("GET /recommendations", c.handleList) mux.HandleFunc("POST /recommendations/search", c.handleSearch) + mux.HandleFunc("POST /recommendations/trigger", c.handleTrigger) return c } @@ -53,6 +54,21 @@ func (c *RecommendationsController) handleList(w http.ResponseWriter, r *http.Re httputil.WriteJSON(w, http.StatusOK, recs) } +// handleTrigger runs the same pipeline as the cron job: recommend + auto-request if Radarr is enabled. +func (c *RecommendationsController) handleTrigger(w http.ResponseWriter, r *http.Request) { + log.Info().Msg("Trigger: running recommendation pipeline") + recs, err := c.rec.Run() + if err != nil { + log.Error().Err(err).Msg("Trigger: pipeline failed") + httputil.WriteError(w, http.StatusInternalServerError, err.Error()) + return + } + if c.cfg.RadarrEnabled { + c.rec.AutoRequest(recs) + } + 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) { diff --git a/internal/service/recommender.go b/internal/service/recommender.go index d1005e5..f5180b2 100644 --- a/internal/service/recommender.go +++ b/internal/service/recommender.go @@ -79,42 +79,58 @@ func (r *Recommender) RunWithCount(count int) ([]model.Recommendation, error) { } } - // 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 + // 3. Retry loop: each attempt requests only the remaining needed count. + // `seen` accumulates every TMDb-resolved movie across attempts so the + // LLM prompt excludes them by name, not just by ID. var recommendations []model.Recommendation - for _, s := range suggestions { - if len(recommendations) >= count { + var seen []model.Movie + for attempt := 0; attempt <= r.cfg.RecommendMaxRetry && len(recommendations) < count; attempt++ { + remaining := count - len(recommendations) + if attempt > 0 { + log.Info().Msgf("Retry %d/%d: need %d more recommendations", attempt, r.cfg.RecommendMaxRetry, remaining) + } + + var suggestions []model.MovieSuggestion + if r.cfg.LLMEnabled && r.llm != nil { + prompt := client.BuildPrompt(favorites, watchedMovies, library, seen, remaining) + suggestions, err = r.llm.Recommend(prompt) + if err != nil { + return recommendations, fmt.Errorf("llm recommendation failed: %w", err) + } + log.Info().Msgf("LLM returned %d suggestions (attempt %d)", len(suggestions), attempt+1) + } else { + suggestions = r.tmdbFallback(signalMovies, exclude, remaining) + log.Info().Msgf("TMDb fallback returned %d suggestions (attempt %d)", len(suggestions), attempt+1) + } + + before := len(recommendations) + 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 + } + seen = append(seen, model.Movie{Name: tm.Title, Year: tm.Year, TMDbID: strconv.Itoa(tm.ID)}) + 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, + }) + } + + if len(recommendations) == before { + log.Warn().Msgf("No new recommendations found on attempt %d, stopping retries", attempt+1) 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 diff --git a/internal/utl/configs/config.go b/internal/utl/configs/config.go index 3b03584..3a3a1fd 100644 --- a/internal/utl/configs/config.go +++ b/internal/utl/configs/config.go @@ -28,8 +28,9 @@ type Config struct { 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"` + RecommendCount int `envconfig:"RECOMMEND_COUNT" default:"10"` + RecommendMaxRetry int `envconfig:"RECOMMEND_MAX_RETRY" default:"3"` + MinRatedMovies int `envconfig:"MIN_RATED_MOVIES" default:"5"` } // Load reads .env (best-effort) then processes environment variables into a Config. diff --git a/internal/utl/httputil/httputil.go b/internal/utl/httputil/httputil.go index 199a1d4..7e1e321 100644 --- a/internal/utl/httputil/httputil.go +++ b/internal/utl/httputil/httputil.go @@ -3,6 +3,7 @@ package httputil import ( "encoding/json" "net/http" + "strings" ) func WriteJSON(w http.ResponseWriter, status int, v any) { @@ -14,3 +15,21 @@ func WriteJSON(w http.ResponseWriter, status int, v any) { func WriteError(w http.ResponseWriter, status int, msg string) { WriteJSON(w, status, map[string]string{"error": msg}) } + +// AuthMiddleware validates the Authorization: Bearer header. +// If secretKey is empty, auth is disabled and all requests pass through. +// /health is always exempt. +func AuthMiddleware(secretKey string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if secretKey == "" || r.URL.Path == "/health" { + next.ServeHTTP(w, r) + return + } + token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || token != secretKey { + WriteError(w, http.StatusUnauthorized, "unauthorized") + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/utl/server/server.go b/internal/utl/server/server.go index ff11419..45e954d 100644 --- a/internal/utl/server/server.go +++ b/internal/utl/server/server.go @@ -7,6 +7,7 @@ import ( "github.com/fjurmanovic/sugarr/internal/api" "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" ) @@ -17,5 +18,8 @@ func Start(cfg *configs.Config, rec *service.Recommender) error { addr := fmt.Sprintf(":%d", cfg.APIPort) log.Info().Msgf("HTTP API listening on %s", addr) - return http.ListenAndServe(addr, mux) + if cfg.APISecretKey != "" { + log.Info().Msg("Secret key auth enabled") + } + return http.ListenAndServe(addr, httputil.AuthMiddleware(cfg.APISecretKey, mux)) }