mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-11 07:41:08 +00:00
216 lines
5.9 KiB
Go
216 lines
5.9 KiB
Go
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
|
|
}
|