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,215 @@
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. 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
var recommendations []model.Recommendation
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
}
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
}
// 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
}