Publish current app and server
This commit is contained in:
@@ -0,0 +1,553 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user