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{
|
2026-08-17 19:09:17 +12:00
|
|
|
ID: "admin:" + contextName, Now: now, Location: s.householdLocation(),
|
2026-08-02 22:10:19 +12:00
|
|
|
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
|
|
|
|
|
}
|