Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:27 +12:00
parent 8d6cf2f5a1
commit 70914400b4
62 changed files with 7501 additions and 744 deletions
+85 -12
View File
@@ -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
}