Big changes
This commit is contained in:
@@ -10,7 +10,9 @@ import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// recencyDecay is applied per position down the history list. At 0.94, the 12th item
|
||||
@@ -26,14 +28,23 @@ const favoriteWeight = 0.6
|
||||
// Item is the slice of an Emby item this package reasons about. The raw payload rides
|
||||
// along so rows can be emitted without re-fetching or re-encoding.
|
||||
type Item struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
SeriesID string `json:"SeriesId"`
|
||||
SeriesName string `json:"SeriesName"`
|
||||
Genres []string `json:"Genres"`
|
||||
CommunityRating float64 `json:"CommunityRating"`
|
||||
Studios []struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
SeriesID string `json:"SeriesId"`
|
||||
SeriesName string `json:"SeriesName"`
|
||||
ProductionYear int `json:"ProductionYear"`
|
||||
Genres []string `json:"Genres"`
|
||||
CommunityRating float64 `json:"CommunityRating"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
ParentIndexNumber int `json:"ParentIndexNumber"`
|
||||
Container string `json:"Container"`
|
||||
MediaStreams []struct {
|
||||
Type string `json:"Type"`
|
||||
Codec string `json:"Codec"`
|
||||
} `json:"MediaStreams"`
|
||||
Studios []struct {
|
||||
Name string `json:"Name"`
|
||||
} `json:"Studios"`
|
||||
UserData struct {
|
||||
@@ -46,6 +57,38 @@ type Item struct {
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func (i Item) RuntimeMinutes() int {
|
||||
if i.RunTimeTicks <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(i.RunTimeTicks / 600_000_000)
|
||||
}
|
||||
|
||||
func (i Item) TitleKey() string {
|
||||
value := i.Name
|
||||
if i.Type == "Episode" && strings.TrimSpace(i.SeriesName) != "" {
|
||||
value = i.SeriesName
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(value) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SeenKey is a title-level fallback for imported catalogue records, whose payloads
|
||||
// deliberately contain no per-user UserData. Movies include their year so watching an
|
||||
// older film does not hide a remake with the same name; episodes collapse to series.
|
||||
func (i Item) SeenKey() string {
|
||||
key := i.TitleKey()
|
||||
if key != "" && strings.EqualFold(i.Type, "Movie") && i.ProductionYear > 0 {
|
||||
return key + "|" + strconv.Itoa(i.ProductionYear)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Seed is a title recent enough to anchor a "Because you watched …" row.
|
||||
type Seed struct {
|
||||
ID string
|
||||
@@ -58,8 +101,9 @@ type Profile struct {
|
||||
StudioWeights map[string]float64
|
||||
// Seen holds item ids *and* series ids already watched or in progress, so a
|
||||
// recommendation never suggests something the user is already partway through.
|
||||
Seen map[string]bool
|
||||
Seeds []Seed
|
||||
Seen map[string]bool
|
||||
SeenTitles map[string]bool
|
||||
Seeds []Seed
|
||||
}
|
||||
|
||||
func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 }
|
||||
@@ -87,12 +131,25 @@ func BuildProfile(history, favorites []Item) Profile {
|
||||
GenreWeights: map[string]float64{},
|
||||
StudioWeights: map[string]float64{},
|
||||
Seen: map[string]bool{},
|
||||
SeenTitles: map[string]bool{},
|
||||
}
|
||||
|
||||
seedSeen := map[string]bool{}
|
||||
tasteSeen := map[string]bool{}
|
||||
for i, item := range history {
|
||||
weight := math.Pow(recencyDecay, float64(i))
|
||||
profile.absorb(item, weight)
|
||||
profile.markSeen(item)
|
||||
|
||||
// Several episodes of one series are evidence for one taste, not several
|
||||
// independent tastes. Keep the newest occurrence's recency weight and still
|
||||
// mark every item/series identifier as seen.
|
||||
tasteID := item.ID
|
||||
if item.SeriesID != "" {
|
||||
tasteID = item.SeriesID
|
||||
}
|
||||
if !tasteSeen[tasteID] {
|
||||
tasteSeen[tasteID] = true
|
||||
profile.absorbTaste(item, math.Pow(recencyDecay, float64(i)))
|
||||
}
|
||||
|
||||
// An episode seeds its series, not itself: "Because you watched Severance"
|
||||
// reads better than "Because you watched Good News".
|
||||
@@ -113,12 +170,25 @@ func BuildProfile(history, favorites []Item) Profile {
|
||||
}
|
||||
|
||||
func (p *Profile) absorb(item Item, weight float64) {
|
||||
p.markSeen(item)
|
||||
p.absorbTaste(item, weight)
|
||||
}
|
||||
|
||||
func (p *Profile) markSeen(item Item) {
|
||||
if item.ID != "" {
|
||||
p.Seen[item.ID] = true
|
||||
}
|
||||
if item.SeriesID != "" {
|
||||
p.Seen[item.SeriesID] = true
|
||||
}
|
||||
if key := item.SeenKey(); key != "" {
|
||||
p.SeenTitles[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// absorbTaste learns affinity without marking the item watched. This is used for
|
||||
// browsing signals: lingering on a card is meaningful, but must not hide that card.
|
||||
func (p *Profile) absorbTaste(item Item, weight float64) {
|
||||
for _, genre := range item.Genres {
|
||||
if g := strings.TrimSpace(genre); g != "" {
|
||||
p.GenreWeights[g] += weight
|
||||
@@ -168,6 +238,9 @@ func (p Profile) Score(candidate Item) float64 {
|
||||
if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] {
|
||||
return -1
|
||||
}
|
||||
if p.SeenTitles[candidate.SeenKey()] {
|
||||
return -1
|
||||
}
|
||||
if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 {
|
||||
return -1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user