package api import ( "context" "encoding/json" "net/http" "net/url" "sort" "strings" "time" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/store" ) func recommendationRow(id string) bool { return id == "recommended" || strings.HasPrefix(id, "for-you:") || strings.HasPrefix(id, "curated:") || strings.HasPrefix(id, "similar:") } // filterRecommendationPermissions makes Emby, using this viewer's token, the final // eligibility authority. The shared imported catalogue can suggest candidates but can // never broaden library access or bypass parental controls. func (s *Server) filterRecommendationPermissions( ctx context.Context, sess store.Session, rows []recommend.Row, ) []recommend.Row { ids := []string{} for _, row := range rows { if !recommendationRow(row.ID) { continue } for _, item := range recommend.Decode(row.Items) { ids = append(ids, item.ID) } } if len(ids) == 0 { return rows } allowed := map[string]bool{} cred := credentials(sess) for start := 0; start < len(ids); start += 100 { end := min(start+100, len(ids)) result, err := s.emby.Items(ctx, cred, rowParams(url.Values{ "Ids": {strings.Join(ids[start:end], ",")}, "Recursive": {"true"}, "IncludeItemTypes": {"Movie,Series"}, "Limit": {itoa(end - start)}, }, fieldsRow)) if err != nil { s.log.Warn("recommendation permission check failed; hiding candidates", "user", sess.EmbyUserID, "error", err) for index := range rows { if recommendationRow(rows[index].ID) { rows[index].Items = []json.RawMessage{} } } return rows } for _, raw := range result.Items { decoded := recommend.Decode([]json.RawMessage{raw}) if len(decoded) == 1 { allowed[decoded[0].ID] = true } } } for index := range rows { if !recommendationRow(rows[index].ID) { continue } filtered := []json.RawMessage{} for _, item := range recommend.Decode(rows[index].Items) { if allowed[item.ID] { filtered = append(filtered, item.Raw) } } rows[index].Items = filtered } return rows } func (s *Server) rankingContext( ctx context.Context, userID string, ) (recommend.WeightedProfile, map[string]recommend.ItemExposure, map[string]float64) { profile := recommend.WeightedProfile{} if s.store == nil { return profile, nil, nil } if raw, err := s.store.WeightedRecommendationProfile(ctx, userID); err == nil { _ = json.Unmarshal(raw, &profile) } else { s.log.Warn("weighted profile unavailable", "user", userID, "error", err) } if profile.ExplicitPositive == nil { profile.ExplicitPositive = map[string]bool{} } if profile.ExplicitNegative == nil { profile.ExplicitNegative = map[string]bool{} } if raw, err := s.store.RecommendationOnboarding(ctx, userID); err == nil { var preferences recommend.OnboardingPreferences if json.Unmarshal(raw, &preferences) == nil { profile.ApplyOnboarding(preferences, s.weightedConfig().MinimumEvidence) ids := make([]string, 0, len(preferences.Ratings)) for id, rating := range preferences.Ratings { if strings.TrimSpace(id) != "" && rating >= 1 && rating <= 5 { ids = append(ids, id) } } if raws, itemErr := s.store.LibraryItemsByID(ctx, ids); itemErr == nil { for _, item := range recommend.Decode(raws) { profile.ApplyOnboardingRating( item, preferences.Ratings[item.ID], s.weightedConfig().MinimumEvidence, ) } } } } if actions, err := s.store.RecommendationActions(ctx, userID); err == nil { ids := make([]string, 0, len(actions)) byID := make(map[string]string, len(actions)) for _, action := range actions { ids = append(ids, action.ItemID) byID[action.ItemID] = action.Action switch action.Action { case "more_like_this": profile.ExplicitPositive[action.ItemID] = true case "not_for_me": profile.ExplicitNegative[action.ItemID] = true } } if raws, itemErr := s.store.LibraryItemsByID(ctx, ids); itemErr == nil { for _, item := range recommend.Decode(raws) { profile.ApplyExplicitPreference(item, byID[item.ID] == "more_like_this") } } } exposures := map[string]recommend.ItemExposure{} if values, err := s.store.UserItemExposures( ctx, userID, time.Now().Add(-45*24*time.Hour), ); err == nil { for _, value := range values { exposures[value.ItemID] = recommend.ItemExposure{ Impressions: value.Impressions, Focuses: value.Focuses, Selects: value.Selects, LastShown: value.LastShown, } } } household, err := s.store.HouseholdCompletionScores(ctx, time.Now().Add(-180*24*time.Hour)) if err != nil { household = map[string]float64{} } return profile, exposures, household } func (s *Server) personalizeTitles( ctx context.Context, sess store.Session, rows []recommend.Row, ) []recommend.Row { if len(rows) == 0 { return rows } profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID) cfg := s.weightedConfig() now := time.Now() location := s.cfg.SonarrLocation for index := range rows { row := &rows[index] compatibility := map[string]float64{} for _, raw := range row.Items { var marker struct { ID string `json:"Id"` Compatibility string `json:"MembyCompatibility"` } if json.Unmarshal(raw, &marker) == nil { switch { case strings.Contains(strings.ToLower(marker.Compatibility), "direct"): compatibility[marker.ID] = 1 case strings.Contains(strings.ToLower(marker.Compatibility), "transcod"): compatibility[marker.ID] = -1 } } } intent := recommend.RankIntent{ ID: row.ID, Now: now, Location: location, HouseholdScores: household, Compatibility: compatibility, } switch { case row.ID == "latest-movies": intent.NewReleasesOnly = true case strings.Contains(row.ID, "one-episode"), strings.Contains(row.ID, "late-night"): intent.PreferShort = true intent.MaxRuntimeMins = 60 case strings.Contains(row.ID, "hidden"): intent.HiddenLibrary = true intent.UnseenOnly = true } ranked := recommend.WeightedRank( profile, recommend.Decode(row.Items), exposures, intent, cfg, len(row.Items), ) items := make([]json.RawMessage, 0, len(ranked)) for _, value := range ranked { items = append(items, recommend.EnrichRankedItem(value)) } // Mandatory progress rows must remain useful even before a profile is prepared. if len(items) > 0 || row.ID != "continue" && row.ID != "next-up" { row.Items = items } } return rows } func (s *Server) personalizeSearch( ctx context.Context, sess store.Session, term string, raws []json.RawMessage, limit int, ) []json.RawMessage { profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID) items := recommend.Decode(raws) relevance := make(map[string]float64, len(items)) wanted := strings.ToLower(strings.TrimSpace(term)) for _, item := range items { name := strings.ToLower(strings.TrimSpace(item.Name)) switch { case name == wanted: relevance[item.ID] = 20 case strings.HasPrefix(name, wanted): relevance[item.ID] = 12 case strings.Contains(name, wanted): relevance[item.ID] = 8 default: relevance[item.ID] = 4 } } ranked := recommend.WeightedRank(profile, items, exposures, recommend.RankIntent{ ID: "search", Now: time.Now(), Location: s.cfg.SonarrLocation, SearchRelevance: relevance, HouseholdScores: household, }, s.weightedConfig(), limit) out := make([]json.RawMessage, 0, len(ranked)) for _, value := range ranked { out = append(out, recommend.EnrichRankedItem(value)) } return out } func (s *Server) weightedConfig() recommend.WeightedConfig { cfg := recommend.DefaultWeightedConfig() if s.cfg.RecommendationWeights != "" { _ = json.Unmarshal([]byte(s.cfg.RecommendationWeights), &cfg) } return cfg } // deduplicateRows gives the earliest row ownership of a title. Continue Watching and // Next Up keep their landmarks; later discovery shelves fill with their remaining // unique posters. func deduplicateRows(rows []recommend.Row) []recommend.Row { seen := map[string]bool{} for rowIndex := range rows { items := recommend.Decode(rows[rowIndex].Items) filtered := make([]json.RawMessage, 0, len(items)) for _, item := range items { key := item.ID if item.SeriesID != "" { key = item.SeriesID } if key == "" || seen[key] { continue } seen[key] = true filtered = append(filtered, item.Raw) } rows[rowIndex].Items = filtered } return rows } // personalizeRowsByTitleScores uses the same title scores to order discovery shelves. // Mandatory shelves receive stable anchors; all other rows compete on the average of // their leading posters, which makes row ordering change with the same profile evidence // that changes poster ordering. func personalizeRowsByTitleScores(rows []recommend.Row) []recommend.Row { type scoredRow struct { row recommend.Row score float64 position int } ranked := make([]scoredRow, 0, len(rows)) for position, row := range rows { score := 0.0 count := 0 for _, raw := range row.Items { var payload struct { Score float64 `json:"MembyRecommendationScore"` } if json.Unmarshal(raw, &payload) == nil { score += payload.Score count++ } if count == 6 { break } } if count > 0 { score /= float64(count) } // A small authored-position prior avoids reshuffling ties and cold starts. score += 0.05 / float64(position+1) switch row.ID { case "continue": score = 1_000 case "next-up": score = 100 case "latest-movies": score = 90 } ranked = append(ranked, scoredRow{row: row, score: score, position: position}) } sort.SliceStable(ranked, func(i, j int) bool { if ranked[i].score != ranked[j].score { return ranked[i].score > ranked[j].score } return ranked[i].position < ranked[j].position }) out := make([]recommend.Row, 0, len(ranked)) for _, value := range ranked { out = append(out, value.row) } return out } func selectPersonalizedRows(rows []recommend.Row) []recommend.Row { out := make([]recommend.Row, 0, len(rows)) for _, row := range rows { switch row.ID { case "continue", "next-up", "latest-movies", "favorites": out = append(out, row) continue } if len(row.Items) == 0 { continue } total, count := 0.0, 0 for _, raw := range row.Items { var payload struct { Score float64 `json:"MembyRecommendationScore"` } if json.Unmarshal(raw, &payload) == nil { total += payload.Score count++ } if count == 6 { break } } if count > 0 && total/float64(count) < -0.25 { continue } out = append(out, row) } return out } type recommendationActionRequest struct { Action string `json:"action"` } func (s *Server) handleRecommendationAction( w http.ResponseWriter, r *http.Request, sess store.Session, ) { itemID := strings.TrimSpace(r.PathValue("id")) if itemID == "" { writeError(w, http.StatusBadRequest, "item id is required") return } var err error if r.Method == http.MethodDelete { err = s.store.ClearRecommendationAction(r.Context(), sess.EmbyUserID, itemID) } else { var req recommendationActionRequest if decodeErr := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); decodeErr != nil { writeError(w, http.StatusBadRequest, "invalid recommendation action") return } err = s.store.SetRecommendationAction( r.Context(), sess.EmbyUserID, itemID, strings.TrimSpace(req.Action), ) } if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } _ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID) if s.forYou != nil { s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess) s.forYou.RefreshAsync(sess, false) } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleRecommendationPreferences( w http.ResponseWriter, r *http.Request, sess store.Session, ) { if r.Method == http.MethodGet { s.handleRecommendationPreferencesGet(w, r, sess) return } var preferences recommend.OnboardingPreferences if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&preferences); err != nil { writeError(w, http.StatusBadRequest, "invalid onboarding preferences") return } if len(preferences.Ratings) > 40 { writeError(w, http.StatusBadRequest, "too many onboarding ratings") return } for id, rating := range preferences.Ratings { if strings.TrimSpace(id) == "" || rating < 1 || rating > 5 { writeError(w, http.StatusBadRequest, "ratings must be between 1 and 5") return } } preferences.Completed = true raw, _ := json.Marshal(preferences) if err := s.store.SetRecommendationOnboarding( r.Context(), sess.EmbyUserID, raw, ); err != nil { writeError(w, http.StatusInternalServerError, "could not save onboarding preferences") return } _ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID) if s.forYou != nil { s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess) s.forYou.RefreshAsync(sess, false) } w.WriteHeader(http.StatusNoContent) } type recommendationOnboardingResponse struct { Completed bool `json:"completed"` Ratings map[string]int `json:"ratings"` Items []json.RawMessage `json:"items"` } func (s *Server) handleRecommendationPreferencesGet( w http.ResponseWriter, r *http.Request, sess store.Session, ) { preferences := recommend.OnboardingPreferences{} if raw, err := s.store.RecommendationOnboarding(r.Context(), sess.EmbyUserID); err == nil { _ = json.Unmarshal(raw, &preferences) } if preferences.Ratings == nil { preferences.Ratings = map[string]int{} } if preferences.Completed { writeJSON(w, http.StatusOK, recommendationOnboardingResponse{ Completed: true, Ratings: preferences.Ratings, Items: []json.RawMessage{}, }) return } raws, err := s.store.AllRecommendationCandidates(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "could not load rating choices") return } candidates := recommendationOnboardingCandidates(recommend.Decode(raws), 24) row := recommend.Row{ID: "for-you:onboarding", Kind: "for-you"} for _, item := range candidates { row.Items = append(row.Items, item.Raw) } filtered := s.filterRecommendationPermissions( r.Context(), sess, []recommend.Row{row}, ) items := []json.RawMessage{} if len(filtered) == 1 { items = filtered[0].Items if len(items) > 16 { items = items[:16] } } writeJSON(w, http.StatusOK, recommendationOnboardingResponse{ Completed: preferences.Completed, Ratings: preferences.Ratings, Items: items, }) } // recommendationOnboardingCandidates selects recognisable, well-rated titles while // keeping movies, series and primary genres mixed. It is deterministic so returning to // an unfinished onboarding screen does not reshuffle the choices. func recommendationOnboardingCandidates(items []recommend.Item, limit int) []recommend.Item { sort.SliceStable(items, func(i, j int) bool { if items[i].CommunityRating != items[j].CommunityRating { return items[i].CommunityRating > items[j].CommunityRating } if items[i].ProductionYear != items[j].ProductionYear { return items[i].ProductionYear > items[j].ProductionYear } return items[i].Name < items[j].Name }) buckets := map[string][]recommend.Item{"movie": {}, "series": {}} typeCounts := map[string]int{} genreCounts := map[string]int{} perType := max(1, limit/2) for _, item := range items { kind := strings.ToLower(strings.TrimSpace(item.Type)) if kind != "movie" && kind != "series" || item.CommunityRating <= 0 || typeCounts[kind] >= perType { continue } genre := "" if len(item.Genres) > 0 { genre = strings.ToLower(strings.TrimSpace(item.Genres[0])) } if genre != "" && genreCounts[genre] >= 3 { continue } buckets[kind] = append(buckets[kind], item) typeCounts[kind]++ genreCounts[genre]++ if typeCounts["movie"]+typeCounts["series"] == limit { break } } out := make([]recommend.Item, 0, limit) for index := 0; len(out) < limit; index++ { added := false for _, kind := range []string{"movie", "series"} { if index < len(buckets[kind]) { out = append(out, buckets[kind][index]) added = true } } if !added { break } } return out }