Files
memby/server/internal/api/admin_recommendations.go
T

243 lines
8.8 KiB
Go
Raw Normal View History

2026-08-02 22:10:19 +12:00
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
}