package api import ( "encoding/json" "net/http" "strings" "time" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/store" ) type adminRecommendationItem struct { ID string `json:"id"` Title string `json:"title"` Type string `json:"type"` Year int `json:"year,omitempty"` Genres []string `json:"genres"` RuntimeMinutes int `json:"runtimeMinutes"` BaseRank int `json:"baseRank"` BaseScore float64 `json:"baseScore"` AffinityScore float64 `json:"affinityScore"` CompatibilityScore float64 `json:"compatibilityScore"` CompatibilityLabel string `json:"compatibilityLabel,omitempty"` PreparedReason string `json:"preparedReason,omitempty"` PreparedReasonKind string `json:"preparedReasonKind,omitempty"` PreparedReasonGenre string `json:"preparedReasonGenre,omitempty"` PreparedEvidenceTitle string `json:"preparedEvidenceTitle,omitempty"` EligibleRows []string `json:"eligibleRows"` Exposure recommend.ItemExposure `json:"exposure"` Explanation recommend.ScoreExplanation `json:"explanation"` } type adminRecommendationProfileMeta struct { SourceEvents int `json:"sourceEvents"` BuiltAt *time.Time `json:"builtAt,omitempty"` PoolBuiltAt *time.Time `json:"poolBuiltAt,omitempty"` DirtySince *time.Time `json:"dirtySince,omitempty"` AlgorithmVersion string `json:"algorithmVersion"` } type adminRecommendationAction struct { ItemID string `json:"itemId"` Title string `json:"title,omitempty"` Action string `json:"action"` UpdatedAt time.Time `json:"updatedAt"` } type adminRecommendationResponse struct { User store.KnownUser `json:"user"` Context string `json:"context"` GeneratedAt time.Time `json:"generatedAt"` Profile recommend.WeightedProfile `json:"profile"` ProfileMeta adminRecommendationProfileMeta `json:"profileMeta"` Actions []adminRecommendationAction `json:"actions"` PoolCandidates int `json:"poolCandidates"` PermissionEligible int `json:"permissionEligible"` Items []adminRecommendationItem `json:"items"` } func (s *Server) handleAdminRecommendations(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.URL.Query().Get("userId")) if userID == "" { writeError(w, http.StatusBadRequest, "userId is required") return } users, err := s.store.KnownUsers(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "could not load users") return } var user store.KnownUser found := false for _, value := range users { if value.ID == userID { user, found = value, true break } } if !found { writeError(w, http.StatusNotFound, "unknown user") return } sessions, err := s.store.ActiveRecommendationUsers(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "could not load user session") return } var session store.Session for _, value := range sessions { if value.EmbyUserID == userID { session = value break } } if session.EmbyUserID == "" { writeError(w, http.StatusConflict, "user has no active Emby session") return } prepared, _, err := s.store.PreparedForYou(r.Context(), userID, 0, 500) if err != nil { writeError(w, http.StatusInternalServerError, "could not load recommendation pool") return } poolCount := len(prepared) permissionRow := recommend.Row{ID: "for-you:admin-pressure-test", Kind: "for-you"} for _, item := range prepared { permissionRow.Items = append(permissionRow.Items, item.Payload) } filtered := s.filterRecommendationPermissions( r.Context(), session, []recommend.Row{permissionRow}, ) allowed := map[string]bool{} if len(filtered) == 1 { for _, item := range recommend.Decode(filtered[0].Items) { allowed[item.ID] = true } } profile, exposures, household := s.rankingContext(r.Context(), userID) now := time.Now() if raw := strings.TrimSpace(r.URL.Query().Get("at")); raw != "" { if parsed, parseErr := time.Parse(time.RFC3339, raw); parseErr == nil { now = parsed } } contextName := strings.TrimSpace(r.URL.Query().Get("context")) if contextName == "" { contextName = "default" } intent := recommend.RankIntent{ ID: "admin:" + contextName, Now: now, Location: s.cfg.SonarrLocation, HouseholdScores: household, Compatibility: map[string]float64{}, } switch contextName { case "default": case "bedtime": intent.PreferShort, intent.MaxRuntimeMins = true, 60 case "hidden": intent.HiddenLibrary, intent.UnseenOnly = true, true case "new-releases": intent.NewReleasesOnly = true default: writeError(w, http.StatusBadRequest, "unknown pressure-test context") return } if minutes := queryInt(r, "minutes", 0, 360); minutes > 0 { intent.MaxRuntimeMins = minutes } candidates := make([]recommend.Item, 0, len(prepared)) byID := make(map[string]store.PreparedForYouItem, len(prepared)) for _, item := range prepared { if !allowed[item.ItemID] { continue } decoded := recommend.Decode([]json.RawMessage{item.Payload}) if len(decoded) != 1 { continue } candidates = append(candidates, decoded[0]) byID[item.ItemID] = item intent.Compatibility[item.ItemID] = item.CompatibilityScore } limit := queryInt(r, "limit", 100, 200) ranked := recommend.WeightedRank( profile, candidates, exposures, intent, s.weightedConfig(), limit, ) items := make([]adminRecommendationItem, 0, len(ranked)) for _, value := range ranked { preparedItem := byID[value.Item.ID] items = append(items, adminRecommendationItem{ ID: value.Item.ID, Title: value.Item.Name, Type: value.Item.Type, Year: value.Item.ProductionYear, Genres: value.Item.Genres, RuntimeMinutes: value.Item.RuntimeMinutes(), BaseRank: preparedItem.BaseRank, BaseScore: preparedItem.BaseScore, AffinityScore: preparedItem.AffinityScore, CompatibilityScore: preparedItem.CompatibilityScore, CompatibilityLabel: preparedItem.CompatibilityLabel, PreparedReason: preparedItem.RecommendationReason, PreparedReasonKind: preparedItem.ReasonKind, PreparedReasonGenre: preparedItem.ReasonGenre, PreparedEvidenceTitle: preparedItem.ReasonSourceTitle, EligibleRows: adminEligibleRows(preparedItem, value.Item), Exposure: exposures[value.Item.ID], Explanation: value.Explanation, }) } storedActions, _ := s.store.RecommendationActions(r.Context(), userID) actionIDs := make([]string, 0, len(storedActions)) for _, action := range storedActions { actionIDs = append(actionIDs, action.ItemID) } actionTitles := map[string]string{} if raws, actionErr := s.store.LibraryItemsByID(r.Context(), actionIDs); actionErr == nil { for _, item := range recommend.Decode(raws) { actionTitles[item.ID] = item.Name } } actions := make([]adminRecommendationAction, 0, len(storedActions)) for _, action := range storedActions { actions = append(actions, adminRecommendationAction{ ItemID: action.ItemID, Title: actionTitles[action.ItemID], Action: action.Action, UpdatedAt: action.UpdatedAt, }) } builtAt, poolBuiltAt, dirtySince, version, _ := s.store.ForYouProfileTimes(r.Context(), userID) writeJSON(w, http.StatusOK, adminRecommendationResponse{ User: user, Context: contextName, GeneratedAt: now, Profile: profile, ProfileMeta: adminRecommendationProfileMeta{ SourceEvents: profile.SourceEvents, BuiltAt: builtAt, PoolBuiltAt: poolBuiltAt, DirtySince: dirtySince, AlgorithmVersion: version, }, Actions: actions, PoolCandidates: poolCount, PermissionEligible: len(candidates), Items: items, }) } func adminEligibleRows(item store.PreparedForYouItem, decoded recommend.Item) []string { rows := []string{"Top picks for you"} if item.ReasonKind == "pick-up" { rows = append(rows, "Pick this show up again") } if item.ReasonSourceTitle != "" { rows = append(rows, "Because you finished "+item.ReasonSourceTitle) } if item.ReasonGenre != "" { rows = append(rows, "More "+item.ReasonGenre+" for you") } if item.CompatibilityScore > 0.2 { rows = append(rows, "Plays well on this TV") } if decoded.Type == "Series" && item.RuntimeMinutes >= 15 && item.RuntimeMinutes <= 50 { rows = append(rows, "One episode before bed") } if item.ReasonKind != "pick-up" { rows = append(rows, "Hidden in your library") } return rows }