package service import ( "fmt" "sort" "strconv" "github.com/fjurmanovic/sugarr/internal/client" "github.com/fjurmanovic/sugarr/internal/model" "github.com/fjurmanovic/sugarr/internal/utl/configs" "github.com/rs/zerolog/log" ) // Recommender orchestrates the full recommendation pipeline. type Recommender struct { cfg *configs.Config jellyfin *client.JellyfinClient tmdb *client.TMDbClient llm *client.LLMClient // nil if LLM disabled radarr *client.RadarrClient // nil if Radarr disabled } func NewRecommender(cfg *configs.Config) *Recommender { r := &Recommender{ cfg: cfg, jellyfin: client.NewJellyfinClient(cfg), tmdb: client.NewTMDbClient(cfg), } if cfg.LLMEnabled { r.llm = client.NewLLMClient(cfg) } if cfg.RadarrEnabled { r.radarr = client.NewRadarrClient(cfg) } return r } func (r *Recommender) Run() ([]model.Recommendation, error) { return r.RunWithCount(r.cfg.RecommendCount) } func (r *Recommender) RunWithCount(count int) ([]model.Recommendation, error) { // 1. Determine signal movies favorites, err := r.jellyfin.GetFavoriteMovies() if err != nil { return nil, fmt.Errorf("fetching favorites: %w", err) } var signalMovies, watchedMovies []model.Movie if len(favorites) >= r.cfg.MinRatedMovies { signalMovies = favorites log.Info().Msgf("Using %d favorite movies as signal", len(favorites)) } else { watchedMovies, err = r.jellyfin.GetRecentlyWatched() if err != nil { return nil, fmt.Errorf("fetching recently watched: %w", err) } signalMovies = watchedMovies log.Info().Msgf("Not enough favorites (%d < %d), falling back to %d recently watched", len(favorites), r.cfg.MinRatedMovies, len(watchedMovies)) } // 2. Build exclusion set from Jellyfin library library, err := r.jellyfin.GetAllMovies() if err != nil { log.Warn().Err(err).Msg("Could not fetch library; exclusion may be incomplete") } exclude := buildExclusionSet(library) // Also exclude movies already in Radarr if r.cfg.RadarrEnabled && r.radarr != nil { if existing, err := r.radarr.GetExisting(); err != nil { log.Warn().Err(err).Msg("Could not fetch Radarr library; exclusion may be incomplete") } else { for _, m := range existing { exclude[m.TmdbId] = true } } } // 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 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 } } return recommendations, nil } // tmdbFallback gathers TMDb similar/recommended movies across signal movies, // ranks by occurrence frequency, and returns top N suggestions. func (r *Recommender) tmdbFallback(signals []model.Movie, exclude map[int]bool, count int) []model.MovieSuggestion { freq := map[int]int{} meta := map[int]model.TMDbMovie{} for _, m := range signals { tmdbID, err := strconv.Atoi(m.TMDbID) if err != nil || tmdbID == 0 { continue } for _, list := range [][]model.TMDbMovie{ mustGetList(r.tmdb.GetSimilar(tmdbID)), mustGetList(r.tmdb.GetRecommendations(tmdbID)), } { for _, tm := range list { if !exclude[tm.ID] { freq[tm.ID]++ meta[tm.ID] = tm } } } } type entry struct{ id, count int } sorted := make([]entry, 0, len(freq)) for id, c := range freq { sorted = append(sorted, entry{id, c}) } sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count }) suggestions := make([]model.MovieSuggestion, 0, count) for _, e := range sorted { if len(suggestions) >= count { break } tm := meta[e.id] suggestions = append(suggestions, model.MovieSuggestion{ Title: tm.Title, Year: tm.Year, Reason: "Recommended by TMDb", }) } return suggestions } // AutoRequest adds all recommendations to Radarr. func (r *Recommender) AutoRequest(recs []model.Recommendation) { if r.radarr == nil { return } for _, rec := range recs { movie, err := r.radarr.Lookup(rec.TMDbID) if err != nil { log.Error().Err(err).Msgf("Radarr lookup failed for %s", rec.Title) continue } if err := r.radarr.AddMovie(*movie); err != nil { log.Error().Err(err).Msgf("Radarr add failed for %s", rec.Title) continue } log.Info().Msgf("Requested in Radarr: %s (%d)", rec.Title, rec.Year) } } // LLM returns the LLM client, or nil if not enabled. func (r *Recommender) LLM() *client.LLMClient { return r.llm } // Radarr returns the Radarr client, or nil if not enabled. func (r *Recommender) Radarr() *client.RadarrClient { return r.radarr } // Jellyfin returns the Jellyfin client. func (r *Recommender) Jellyfin() *client.JellyfinClient { return r.jellyfin } // TMDb returns the TMDb client. func (r *Recommender) TMDb() *client.TMDbClient { return r.tmdb } func buildExclusionSet(movies []model.Movie) map[int]bool { exclude := map[int]bool{} for _, m := range movies { if id, err := strconv.Atoi(m.TMDbID); err == nil && id != 0 { exclude[id] = true } } return exclude } func mustGetList(movies []model.TMDbMovie, _ error) []model.TMDbMovie { if movies == nil { return nil } return movies }