Big changes
This commit is contained in:
@@ -4,13 +4,16 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/tracearr"
|
||||
)
|
||||
|
||||
// Row is one horizontal strip on the TV home screen.
|
||||
@@ -28,6 +31,13 @@ type Source interface {
|
||||
Similar(ctx context.Context, cred emby.Credentials, itemID string, params url.Values) (*emby.ItemsResult, error)
|
||||
}
|
||||
|
||||
// NextUpSource is optional because the on-demand engine and small test sources do not
|
||||
// need it. The production Emby client implements it; prepared rebuilds use one Next Up
|
||||
// request to prove that an abandoned programme still has an unwatched episode.
|
||||
type NextUpSource interface {
|
||||
NextUp(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error)
|
||||
}
|
||||
|
||||
// LibrarySource is the imported catalogue. When present, the candidate pool comes from
|
||||
// Postgres instead of Emby, which takes the rebuild off Emby entirely.
|
||||
type LibrarySource interface {
|
||||
@@ -44,15 +54,29 @@ type CuratedLibrarySource interface {
|
||||
) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
type TracearrSource interface {
|
||||
History(ctx context.Context, username string, limit int) ([]tracearr.Session, error)
|
||||
}
|
||||
|
||||
type BehaviorSource interface {
|
||||
BrowsingCandidates(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
since time.Time,
|
||||
limit int,
|
||||
) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
// CuratedRow defines one reusable server-side shelf. Filtering determines membership;
|
||||
// the user's profile determines both item order and shelf order.
|
||||
type CuratedRow struct {
|
||||
ID string
|
||||
Title string
|
||||
Kind string
|
||||
ItemTypes []string
|
||||
Genres []string
|
||||
Studios []string
|
||||
ID string
|
||||
Title string
|
||||
Kind string
|
||||
ItemTypes []string
|
||||
Genres []string
|
||||
Studios []string
|
||||
RequireAffinity bool
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
@@ -60,7 +84,9 @@ type Engine struct {
|
||||
log *slog.Logger
|
||||
|
||||
// Library is optional; nil (or an empty library) falls back to querying Emby.
|
||||
Library LibrarySource
|
||||
Library LibrarySource
|
||||
Tracearr TracearrSource
|
||||
Behavior BehaviorSource
|
||||
|
||||
// MinRowItems is the shortest row worth showing. A two-item "Recommended" strip
|
||||
// looks broken next to full rows, so short rows are dropped entirely.
|
||||
@@ -72,6 +98,333 @@ type Engine struct {
|
||||
CuratedRows []CuratedRow
|
||||
}
|
||||
|
||||
// ForYouOptions are request-scoped constraints chosen on the television.
|
||||
type ForYouOptions struct {
|
||||
// AvailableMinutes is zero for no time limit.
|
||||
AvailableMinutes int
|
||||
}
|
||||
|
||||
type compatibilityProfile struct {
|
||||
directCodecs map[string]int
|
||||
transcodeCodecs map[string]int
|
||||
}
|
||||
|
||||
// BuildForYou creates the dedicated, explainable recommendation area. Emby supplies
|
||||
// catalogue metadata, Tracearr supplies completion and real device/playback outcomes,
|
||||
// and Memby's own row analytics supplies browsing intent.
|
||||
func (e *Engine) BuildForYou(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
username string,
|
||||
options ForYouOptions,
|
||||
) ([]Row, error) {
|
||||
history, favorites, err := e.gatherSignals(ctx, cred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile := BuildProfile(history, favorites)
|
||||
|
||||
var sessions []tracearr.Session
|
||||
if e.Tracearr != nil {
|
||||
if fetched, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
|
||||
e.log.Warn("tracearr history unavailable; using emby signals", "error", traceErr)
|
||||
} else {
|
||||
sessions = recommendationSessions(fetched)
|
||||
e.applyTracearrSignals(&profile, history, sessions)
|
||||
}
|
||||
}
|
||||
|
||||
browsed := map[string]bool{}
|
||||
if e.Behavior != nil {
|
||||
raws, browseErr := e.Behavior.BrowsingCandidates(
|
||||
ctx,
|
||||
cred.UserID,
|
||||
time.Now().Add(-30*24*time.Hour),
|
||||
30,
|
||||
)
|
||||
if browseErr != nil {
|
||||
e.log.Warn("browsing signals unavailable", "error", browseErr)
|
||||
} else {
|
||||
for i, item := range Decode(raws) {
|
||||
weight := 0.55 * powDecay(0.92, i)
|
||||
profile.absorbTaste(item, weight)
|
||||
browsed[item.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
genres := profile.TopGenres(4)
|
||||
if len(genres) == 0 {
|
||||
return []Row{}, nil
|
||||
}
|
||||
candidates, ok := e.libraryCandidatesForYou(ctx, cred, genres)
|
||||
if !ok {
|
||||
return []Row{}, nil
|
||||
}
|
||||
|
||||
compatibility := buildCompatibilityProfile(sessions)
|
||||
items := rankForYou(profile, candidates, options.AvailableMinutes, compatibility, e.RowSize)
|
||||
if len(items) < e.MinRowItems {
|
||||
return []Row{}, nil
|
||||
}
|
||||
|
||||
raws := make([]json.RawMessage, 0, len(items))
|
||||
for _, item := range items {
|
||||
reason, compatibilityLabel := explainRecommendation(
|
||||
profile,
|
||||
item,
|
||||
options.AvailableMinutes,
|
||||
compatibility,
|
||||
browsed[item.ID],
|
||||
)
|
||||
raws = append(raws, enrichRecommendation(item.Raw, reason, compatibilityLabel))
|
||||
}
|
||||
title := "Top picks for you"
|
||||
if options.AvailableMinutes > 0 {
|
||||
title = "Top picks that fit in " + strconv.Itoa(options.AvailableMinutes) + " minutes"
|
||||
}
|
||||
return []Row{{
|
||||
ID: "for-you:picks",
|
||||
Title: title,
|
||||
Kind: "for-you",
|
||||
Items: raws,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func powDecay(base float64, position int) float64 {
|
||||
return math.Pow(base, float64(position))
|
||||
}
|
||||
|
||||
func (e *Engine) applyTracearrSignals(
|
||||
profile *Profile,
|
||||
history []Item,
|
||||
sessions []tracearr.Session,
|
||||
) {
|
||||
byTitle := make(map[string]Item, len(history))
|
||||
for _, item := range history {
|
||||
byTitle[item.TitleKey()] = item
|
||||
}
|
||||
for i, session := range sessions {
|
||||
if session.Completion() > 0 {
|
||||
profile.SeenTitles[tracearrSeenKey(session)] = true
|
||||
}
|
||||
item, ok := byTitle[session.TitleKey()]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Completion distinguishes "finished it twice" from "abandoned after ten
|
||||
// minutes"; recency lets changing tastes move promptly.
|
||||
weight := (0.2 + session.Completion()) * powDecay(0.985, i)
|
||||
profile.absorbTaste(item, weight)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) libraryCandidatesForYou(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
genres []string,
|
||||
) ([]Item, bool) {
|
||||
if e.Library != nil {
|
||||
raws, err := e.Library.LibraryCandidates(ctx, genres, e.RowSize*12)
|
||||
if err == nil && len(raws) > 0 {
|
||||
return Decode(raws), true
|
||||
}
|
||||
if err != nil {
|
||||
e.log.Warn("for-you library candidates failed; falling back to emby", "error", err)
|
||||
}
|
||||
}
|
||||
result, err := e.source.Items(ctx, cred, url.Values{
|
||||
"IncludeItemTypes": {"Movie,Series"},
|
||||
"Recursive": {"true"},
|
||||
"Filters": {"IsUnplayed"},
|
||||
"Genres": {strings.Join(genres, "|")},
|
||||
"SortBy": {"CommunityRating"},
|
||||
"SortOrder": {"Descending"},
|
||||
"Limit": {strconv.Itoa(e.RowSize * 8)},
|
||||
"Fields": {candidateFields + ",MediaStreams,Container"},
|
||||
"ImageTypeLimit": {"1"},
|
||||
"EnableImages": {"true"},
|
||||
"EnableImageTypes": {rowImageTypes},
|
||||
"EnableUserData": {"true"},
|
||||
})
|
||||
if err != nil {
|
||||
e.log.Warn("for-you emby candidates failed", "error", err)
|
||||
return nil, false
|
||||
}
|
||||
return Decode(result.Items), len(result.Items) > 0
|
||||
}
|
||||
|
||||
func buildCompatibilityProfile(sessions []tracearr.Session) compatibilityProfile {
|
||||
profile := compatibilityProfile{
|
||||
directCodecs: map[string]int{},
|
||||
transcodeCodecs: map[string]int{},
|
||||
}
|
||||
for _, session := range sessions {
|
||||
if !session.IsTelevisionSession() {
|
||||
continue
|
||||
}
|
||||
for _, codec := range []string{session.SourceVideoCodec, session.SourceAudioCodec} {
|
||||
codec = strings.ToLower(strings.TrimSpace(codec))
|
||||
if codec == "" {
|
||||
continue
|
||||
}
|
||||
if session.IsTranscode ||
|
||||
strings.EqualFold(session.VideoDecision, "transcode") ||
|
||||
strings.EqualFold(session.AudioDecision, "transcode") {
|
||||
profile.transcodeCodecs[codec]++
|
||||
} else {
|
||||
profile.directCodecs[codec]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func compatibilityScore(item Item, profile compatibilityProfile) float64 {
|
||||
var score float64
|
||||
var known int
|
||||
for _, stream := range item.MediaStreams {
|
||||
if !strings.EqualFold(stream.Type, "Video") && !strings.EqualFold(stream.Type, "Audio") {
|
||||
continue
|
||||
}
|
||||
codec := strings.ToLower(strings.TrimSpace(stream.Codec))
|
||||
direct, transcode := profile.directCodecs[codec], profile.transcodeCodecs[codec]
|
||||
if direct+transcode == 0 {
|
||||
continue
|
||||
}
|
||||
known++
|
||||
score += float64(direct-transcode) / float64(direct+transcode)
|
||||
}
|
||||
if known == 0 {
|
||||
return 0
|
||||
}
|
||||
return score / float64(known)
|
||||
}
|
||||
|
||||
func rankForYou(
|
||||
profile Profile,
|
||||
candidates []Item,
|
||||
availableMinutes int,
|
||||
compatibility compatibilityProfile,
|
||||
limit int,
|
||||
) []Item {
|
||||
type scored struct {
|
||||
item Item
|
||||
score float64
|
||||
}
|
||||
ranked := make([]scored, 0, len(candidates))
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range candidates {
|
||||
if seen[candidate.ID] {
|
||||
continue
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
base := profile.Score(candidate)
|
||||
if base < 0 {
|
||||
continue
|
||||
}
|
||||
runtime := candidate.RuntimeMinutes()
|
||||
if availableMinutes > 0 && (runtime <= 0 || runtime > availableMinutes) {
|
||||
continue
|
||||
}
|
||||
score := base + compatibilityScore(candidate, compatibility)*1.4
|
||||
if availableMinutes > 0 {
|
||||
// Prefer a satisfying fit over something dramatically shorter, without
|
||||
// allowing runtime to overwhelm taste.
|
||||
score += float64(runtime) / float64(availableMinutes) * 0.35
|
||||
}
|
||||
ranked = append(ranked, scored{item: candidate, score: score})
|
||||
}
|
||||
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].item.Name < ranked[j].item.Name
|
||||
})
|
||||
if limit > 0 && len(ranked) > limit {
|
||||
ranked = ranked[:limit]
|
||||
}
|
||||
out := make([]Item, 0, len(ranked))
|
||||
for _, entry := range ranked {
|
||||
out = append(out, entry.item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func explainRecommendation(
|
||||
profile Profile,
|
||||
item Item,
|
||||
availableMinutes int,
|
||||
compatibility compatibilityProfile,
|
||||
browsed bool,
|
||||
) (string, string) {
|
||||
top := profile.TopGenres(5)
|
||||
matched := ""
|
||||
for _, wanted := range top {
|
||||
for _, genre := range item.Genres {
|
||||
if strings.EqualFold(wanted, genre) {
|
||||
matched = genre
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
reasons := make([]string, 0, 3)
|
||||
if browsed {
|
||||
reasons = append(reasons, "You explored this recently")
|
||||
} else if matched != "" {
|
||||
reasons = append(reasons, "Matches your "+matched+" viewing")
|
||||
} else if len(profile.Seeds) > 0 {
|
||||
reasons = append(reasons, "Inspired by "+profile.Seeds[0].Name)
|
||||
} else {
|
||||
reasons = append(reasons, "Matches your recent viewing")
|
||||
}
|
||||
if availableMinutes > 0 && item.RuntimeMinutes() > 0 {
|
||||
reasons = append(reasons, "fits your "+strconv.Itoa(availableMinutes)+"-minute window")
|
||||
}
|
||||
compatibilityLabel := ""
|
||||
switch score := compatibilityScore(item, compatibility); {
|
||||
case score > 0.2:
|
||||
compatibilityLabel = "Direct plays well on this TV"
|
||||
reasons = append(reasons, compatibilityLabel)
|
||||
case score < -0.2:
|
||||
compatibilityLabel = "May need transcoding on this TV"
|
||||
default:
|
||||
compatibilityLabel = "TV compatibility not yet learned"
|
||||
}
|
||||
return strings.Join(reasons, " · "), compatibilityLabel
|
||||
}
|
||||
|
||||
func enrichRecommendation(raw json.RawMessage, reason, compatibility string) json.RawMessage {
|
||||
var item map[string]any
|
||||
if err := json.Unmarshal(raw, &item); err != nil {
|
||||
return raw
|
||||
}
|
||||
item["MembyRecommendationReason"] = reason
|
||||
item["MembyCompatibility"] = compatibility
|
||||
enriched, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return enriched
|
||||
}
|
||||
|
||||
// EnrichPreparedRecommendation adds request-time details to a candidate selected from
|
||||
// the prepared PostgreSQL pool. The stored reason remains stable; only the viewer's
|
||||
// current time budget is appended here.
|
||||
func EnrichPreparedRecommendation(
|
||||
raw json.RawMessage,
|
||||
reason, compatibility string,
|
||||
availableMinutes int,
|
||||
) json.RawMessage {
|
||||
if availableMinutes > 0 {
|
||||
reason += " · fits your " + strconv.Itoa(availableMinutes) + "-minute window"
|
||||
}
|
||||
return enrichRecommendation(raw, reason, compatibility)
|
||||
}
|
||||
|
||||
func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
return &Engine{
|
||||
source: source,
|
||||
@@ -89,22 +442,81 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
},
|
||||
{
|
||||
ID: "curated:drama-shows",
|
||||
Title: "Drama TV Shows",
|
||||
Title: "Drama Shows",
|
||||
Kind: "shows",
|
||||
ItemTypes: []string{"Series"},
|
||||
Genres: []string{"Drama"},
|
||||
},
|
||||
{
|
||||
ID: "curated:comedy-shows",
|
||||
Title: "Comedy TV Shows",
|
||||
Title: "Comedy Shows",
|
||||
Kind: "shows",
|
||||
ItemTypes: []string{"Series"},
|
||||
Genres: []string{"Comedy"},
|
||||
},
|
||||
{
|
||||
ID: "curated:horror-shows",
|
||||
Title: "Horror Shows",
|
||||
Kind: "shows",
|
||||
ItemTypes: []string{"Series"},
|
||||
Genres: []string{"Horror"},
|
||||
},
|
||||
// Movie shelves are deliberately numerous definitions but sparse output:
|
||||
// only the user's six strongest matching genres and three strongest studio
|
||||
// families survive buildCuratedRows.
|
||||
movieGenreRow("action", "Action"),
|
||||
movieGenreRow("adventure", "Adventure"),
|
||||
movieGenreRow("animation", "Animation"),
|
||||
movieGenreRow("comedy", "Comedy"),
|
||||
movieGenreRow("crime", "Crime"),
|
||||
movieGenreRow("documentary", "Documentary"),
|
||||
movieGenreRow("drama", "Drama"),
|
||||
movieGenreRow("family", "Family"),
|
||||
movieGenreRow("fantasy", "Fantasy"),
|
||||
movieGenreRow("horror", "Horror"),
|
||||
movieGenreRow("mystery", "Mystery"),
|
||||
movieGenreRow("romance", "Romance"),
|
||||
movieGenreRow("science-fiction", "Science Fiction"),
|
||||
movieGenreRow("thriller", "Thriller"),
|
||||
movieStudioRow("pixar", "Pixar", "Pixar", "Pixar Animation Studios"),
|
||||
movieStudioRow(
|
||||
"disney", "Disney",
|
||||
"Disney", "Walt Disney Pictures", "Walt Disney Animation Studios",
|
||||
),
|
||||
movieStudioRow("marvel", "Marvel Studios", "Marvel Studios"),
|
||||
movieStudioRow("lucasfilm", "Lucasfilm", "Lucasfilm", "Lucasfilm Ltd."),
|
||||
movieStudioRow(
|
||||
"dreamworks", "DreamWorks",
|
||||
"DreamWorks", "DreamWorks Pictures", "DreamWorks Animation",
|
||||
),
|
||||
movieStudioRow(
|
||||
"warner-bros", "Warner Bros.",
|
||||
"Warner Bros.", "Warner Bros. Pictures", "Warner Brothers",
|
||||
),
|
||||
movieStudioRow(
|
||||
"universal", "Universal",
|
||||
"Universal Pictures", "Universal Studios",
|
||||
),
|
||||
movieStudioRow("a24", "A24", "A24"),
|
||||
movieStudioRow("studio-ghibli", "Studio Ghibli", "Studio Ghibli"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func movieGenreRow(id, genre string) CuratedRow {
|
||||
return CuratedRow{
|
||||
ID: "curated:movies:genre:" + id, Title: genre + " Movies", Kind: "movies",
|
||||
ItemTypes: []string{"Movie"}, Genres: []string{genre}, RequireAffinity: true,
|
||||
}
|
||||
}
|
||||
|
||||
func movieStudioRow(id, title string, studios ...string) CuratedRow {
|
||||
return CuratedRow{
|
||||
ID: "curated:movies:studio:" + id, Title: "More from " + title, Kind: "movies",
|
||||
ItemTypes: []string{"Movie"}, Studios: studios, RequireAffinity: true,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks"
|
||||
candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
|
||||
@@ -152,11 +564,22 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
}
|
||||
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
|
||||
for order, definition := range e.CuratedRows {
|
||||
definitions = append(definitions, rankedDefinition{
|
||||
affinity := profile.CollectionAffinity(definition.Genres, definition.Studios)
|
||||
// Studio signals are intentionally damped while scoring individual titles.
|
||||
// Restore enough weight at shelf level for a genuinely followed studio to earn
|
||||
// a row before genre shelves consume all of its films during deduplication.
|
||||
if strings.HasPrefix(definition.ID, "curated:movies:studio:") {
|
||||
affinity *= 3
|
||||
}
|
||||
ranked := rankedDefinition{
|
||||
definition: definition,
|
||||
affinity: profile.CollectionAffinity(definition.Genres, definition.Studios),
|
||||
affinity: affinity,
|
||||
order: order,
|
||||
})
|
||||
}
|
||||
if definition.RequireAffinity && ranked.affinity <= 0 {
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, ranked)
|
||||
}
|
||||
sort.SliceStable(definitions, func(i, j int) bool {
|
||||
if definitions[i].affinity != definitions[j].affinity {
|
||||
@@ -166,8 +589,23 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
})
|
||||
|
||||
rows := make([]Row, 0, len(definitions))
|
||||
// A series may carry several genres. Give it to the user's highest-affinity shelf
|
||||
// only, so scrolling Shows never reveals the same card again under another label.
|
||||
seenItems := make(map[string]struct{})
|
||||
movieGenreRows := 0
|
||||
movieStudioRows := 0
|
||||
for _, ranked := range definitions {
|
||||
definition := ranked.definition
|
||||
switch {
|
||||
case strings.HasPrefix(definition.ID, "curated:movies:genre:"):
|
||||
if movieGenreRows >= 6 {
|
||||
continue
|
||||
}
|
||||
case strings.HasPrefix(definition.ID, "curated:movies:studio:"):
|
||||
if movieStudioRows >= 3 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
raws, err := library.CuratedCandidates(
|
||||
ctx,
|
||||
definition.ItemTypes,
|
||||
@@ -179,16 +617,35 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
e.log.Warn("curated row failed", "row", definition.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
items := RankCollection(profile, Decode(raws), e.RowSize)
|
||||
rankedItems := RankCollection(profile, Decode(raws), e.RowSize*2)
|
||||
items := make([]Item, 0, e.RowSize)
|
||||
for _, item := range rankedItems {
|
||||
if _, seen := seenItems[item.ID]; seen {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
if len(items) == e.RowSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(items) < e.MinRowItems {
|
||||
continue
|
||||
}
|
||||
for _, item := range items {
|
||||
seenItems[item.ID] = struct{}{}
|
||||
}
|
||||
rows = append(rows, Row{
|
||||
ID: definition.ID,
|
||||
Title: definition.Title,
|
||||
Kind: definition.Kind,
|
||||
Items: Raws(items),
|
||||
})
|
||||
if strings.HasPrefix(definition.ID, "curated:movies:genre:") {
|
||||
movieGenreRows++
|
||||
}
|
||||
if strings.HasPrefix(definition.ID, "curated:movies:studio:") {
|
||||
movieStudioRows++
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -227,18 +684,20 @@ func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (hist
|
||||
"Recursive": {"true"},
|
||||
"SortBy": {"DatePlayed"},
|
||||
"SortOrder": {"Descending"},
|
||||
"Limit": {"20"},
|
||||
"Fields": {historyFields},
|
||||
"EnableUserData": {"true"},
|
||||
"EnableImages": {"false"},
|
||||
// Imported catalogue rows have no user state, so this query is also the
|
||||
// exclusion set. Household libraries are small enough to fetch it completely.
|
||||
"Limit": {"5000"},
|
||||
"Fields": {historyFields},
|
||||
"EnableUserData": {"true"},
|
||||
"EnableImages": {"false"},
|
||||
})
|
||||
fetch(&played, url.Values{
|
||||
"Filters": {"IsPlayed"},
|
||||
"IncludeItemTypes": {"Movie,Episode"},
|
||||
"IncludeItemTypes": {"Movie,Episode,Series"},
|
||||
"Recursive": {"true"},
|
||||
"SortBy": {"DatePlayed"},
|
||||
"SortOrder": {"Descending"},
|
||||
"Limit": {"60"},
|
||||
"Limit": {"5000"},
|
||||
"Fields": {historyFields},
|
||||
"EnableUserData": {"true"},
|
||||
"EnableImages": {"false"},
|
||||
@@ -261,6 +720,28 @@ func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (hist
|
||||
return append(resumable, played...), favorites, nil
|
||||
}
|
||||
|
||||
func recommendationSessions(sessions []tracearr.Session) []tracearr.Session {
|
||||
out := make([]tracearr.Session, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
// Prerolls are delivery mechanics, not a viewer choice. Treating dozens of
|
||||
// completed prerolls as taste evidence overwhelms real household history.
|
||||
if strings.HasPrefix(session.TitleKey(), "preroll") {
|
||||
continue
|
||||
}
|
||||
out = append(out, session)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tracearrSeenKey(session tracearr.Session) string {
|
||||
key := session.TitleKey()
|
||||
if key != "" && strings.EqualFold(session.MediaType, "movie") &&
|
||||
session.Year != nil && *session.Year > 0 {
|
||||
return key + "|" + strconv.Itoa(*session.Year)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// libraryCandidates reads the pool from the imported library. Returns ok=false when
|
||||
// there is no library, it is empty, or it errors — every one of which means "ask Emby".
|
||||
func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item, bool) {
|
||||
|
||||
@@ -7,11 +7,14 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/tracearr"
|
||||
)
|
||||
|
||||
// fakeSource records the queries the engine makes and replays canned answers.
|
||||
@@ -22,6 +25,8 @@ type fakeSource struct {
|
||||
similar map[string][]json.RawMessage
|
||||
itemsErr error
|
||||
similarErr error
|
||||
nextUp []json.RawMessage
|
||||
nextUpErr error
|
||||
|
||||
genreQueries []string
|
||||
similarSeeds []string
|
||||
@@ -31,6 +36,36 @@ type fakeCuratedLibrary struct {
|
||||
byGenre map[string][]json.RawMessage
|
||||
}
|
||||
|
||||
type fakeForYouLibrary struct {
|
||||
items []json.RawMessage
|
||||
}
|
||||
|
||||
func (f *fakeForYouLibrary) AllRecommendationCandidates(
|
||||
_ context.Context,
|
||||
) ([]json.RawMessage, error) {
|
||||
return f.items, nil
|
||||
}
|
||||
|
||||
func (f *fakeForYouLibrary) LibraryCandidates(
|
||||
_ context.Context,
|
||||
_ []string,
|
||||
_ int,
|
||||
) ([]json.RawMessage, error) {
|
||||
return f.items, nil
|
||||
}
|
||||
|
||||
type fakeTracearr struct {
|
||||
sessions []tracearr.Session
|
||||
}
|
||||
|
||||
func (f fakeTracearr) History(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
_ int,
|
||||
) ([]tracearr.Session, error) {
|
||||
return f.sessions, nil
|
||||
}
|
||||
|
||||
func (f *fakeCuratedLibrary) LibraryCandidates(
|
||||
_ context.Context,
|
||||
_ []string,
|
||||
@@ -62,7 +97,11 @@ func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Val
|
||||
f.genreQueries = append(f.genreQueries, genres)
|
||||
}
|
||||
key := params.Get("Filters")
|
||||
return &emby.ItemsResult{Items: f.itemsByFilter[key]}, nil
|
||||
items := f.itemsByFilter[key]
|
||||
if limit, err := strconv.Atoi(params.Get("Limit")); err == nil && limit > 0 && len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return &emby.ItemsResult{Items: items}, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID string, _ url.Values) (*emby.ItemsResult, error) {
|
||||
@@ -75,6 +114,69 @@ func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID strin
|
||||
return &emby.ItemsResult{Items: f.similar[itemID]}, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) NextUp(
|
||||
_ context.Context,
|
||||
_ emby.Credentials,
|
||||
_ url.Values,
|
||||
) (*emby.ItemsResult, error) {
|
||||
if f.nextUpErr != nil {
|
||||
return nil, f.nextUpErr
|
||||
}
|
||||
return &emby.ItemsResult{Items: f.nextUp}, nil
|
||||
}
|
||||
|
||||
func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T) {
|
||||
now := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
|
||||
catalogue := Decode([]json.RawMessage{
|
||||
json.RawMessage(`{"Id":"early","Name":"Early Show","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"deep","Name":"Deep Show","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"complete","Name":"Complete Show","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"recent","Name":"Recent Show","Type":"Series"}`),
|
||||
})
|
||||
session := func(show string, season, episode, daysAgo int) tracearr.Session {
|
||||
value := tracearr.Session{
|
||||
ID: "session-" + show, MediaType: "episode", ShowTitle: show,
|
||||
SeasonNumber: intPointer(season), EpisodeNumber: intPointer(episode),
|
||||
Watched: true, StoppedAt: now.AddDate(0, 0, -daysAgo).Format(time.RFC3339Nano),
|
||||
}
|
||||
return value
|
||||
}
|
||||
sessions := []tracearr.Session{
|
||||
session("Early Show", 1, 2, 40),
|
||||
session("Deep Show", 2, 8, 60),
|
||||
session("Complete Show", 4, 10, 50),
|
||||
session("Recent Show", 1, 3, 5),
|
||||
}
|
||||
nextUp := Decode([]json.RawMessage{
|
||||
json.RawMessage(`{"Id":"early-next","Type":"Episode","SeriesId":"early","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
|
||||
json.RawMessage(`{"Id":"deep-next","Type":"Episode","SeriesId":"deep","ParentIndexNumber":3,"IndexNumber":1,"RunTimeTicks":36000000000}`),
|
||||
json.RawMessage(`{"Id":"recent-next","Type":"Episode","SeriesId":"recent","ParentIndexNumber":1,"IndexNumber":4}`),
|
||||
})
|
||||
|
||||
got := abandonedShowCandidates(
|
||||
newCatalogueIndex(catalogue),
|
||||
sessions,
|
||||
nextUp,
|
||||
compatibilityProfile{directCodecs: map[string]int{}, transcodeCodecs: map[string]int{}},
|
||||
now,
|
||||
)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("pickup candidates = %+v, want only two abandoned unfinished shows", got)
|
||||
}
|
||||
if got[0].ItemID != "deep" || got[1].ItemID != "early" {
|
||||
t.Fatalf("pickup order = %q, %q; later-season commitment should lead", got[0].ItemID, got[1].ItemID)
|
||||
}
|
||||
if got[0].RecommendationReason != "You made it through season 2 · season 3 is waiting" {
|
||||
t.Fatalf("later-season reason = %q", got[0].RecommendationReason)
|
||||
}
|
||||
if got[1].RecommendationReason != "You left this in season 1 · pick it up again" {
|
||||
t.Fatalf("season-one reason = %q", got[1].RecommendationReason)
|
||||
}
|
||||
if got[0].RuntimeMinutes != 60 || got[1].RuntimeMinutes != 30 {
|
||||
t.Fatalf("next-episode runtimes = %d, %d", got[0].RuntimeMinutes, got[1].RuntimeMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
func raw(id, name, itemType string, genres ...string) json.RawMessage {
|
||||
quoted := make([]string, 0, len(genres))
|
||||
for _, g := range genres {
|
||||
@@ -128,6 +230,117 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForYouFiltersTimeAndAddsExplanation(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsResumable": {},
|
||||
"IsPlayed": {
|
||||
json.RawMessage(`{"Id":"seen","Name":"Arrival","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":69600000000}`),
|
||||
},
|
||||
"IsFavorite": {},
|
||||
},
|
||||
}
|
||||
engine := testEngine(source)
|
||||
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
|
||||
json.RawMessage(`{"Id":"short","Name":"Moon","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":54000000000,"MediaStreams":[{"Type":"Video","Codec":"h264"}]}`),
|
||||
json.RawMessage(`{"Id":"long","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":93000000000,"MediaStreams":[{"Type":"Video","Codec":"hevc"}]}`),
|
||||
}}
|
||||
session := tracearr.Session{
|
||||
MediaType: "movie",
|
||||
MediaTitle: "Arrival",
|
||||
Watched: true,
|
||||
Platform: "Android TV",
|
||||
SourceVideoCodec: "h264",
|
||||
VideoDecision: "directplay",
|
||||
}
|
||||
engine.Tracearr = fakeTracearr{sessions: []tracearr.Session{session}}
|
||||
engine.MinRowItems = 1
|
||||
|
||||
rows, err := engine.BuildForYou(
|
||||
context.Background(),
|
||||
emby.Credentials{UserID: "u1"},
|
||||
"Matt",
|
||||
ForYouOptions{AvailableMinutes: 100},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || len(rows[0].Items) != 1 {
|
||||
t.Fatalf("rows = %+v", rows)
|
||||
}
|
||||
if !strings.Contains(string(rows[0].Items[0]), `"MembyRecommendationReason"`) ||
|
||||
!strings.Contains(string(rows[0].Items[0]), `100-minute window`) {
|
||||
t.Fatalf("explanation missing: %s", rows[0].Items[0])
|
||||
}
|
||||
if strings.Contains(string(rows[0].Items[0]), `"Id":"long"`) {
|
||||
t.Fatalf("over-budget item was retained: %s", rows[0].Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareForYouKeepsAnOverProvisionedPoolAndSpecificEvidence(t *testing.T) {
|
||||
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsResumable": {},
|
||||
"IsPlayed": {
|
||||
json.RawMessage(`{"Id":"watched","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"],"UserData":{"Played":true}}`),
|
||||
},
|
||||
"IsFavorite": {},
|
||||
}}
|
||||
catalogue := []json.RawMessage{
|
||||
json.RawMessage(`{"Id":"arrival","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"],"RunTimeTicks":69600000000}`),
|
||||
}
|
||||
for i := 0; i < 30; i++ {
|
||||
catalogue = append(catalogue, json.RawMessage(
|
||||
`{"Id":"candidate-`+strconv.Itoa(i)+`","Name":"Candidate `+strconv.Itoa(i)+
|
||||
`","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":54000000000}`,
|
||||
))
|
||||
}
|
||||
engine := testEngine(source)
|
||||
engine.Library = &fakeForYouLibrary{items: catalogue}
|
||||
session := tracearr.Session{
|
||||
ID: "trace-1", ServerID: "server-1", MediaTitle: "Arrival",
|
||||
MediaType: "movie", Year: intPointer(2016), Watched: true,
|
||||
StartedAt: "2026-07-20T08:00:00Z",
|
||||
}
|
||||
session.User.ID = "trace-user"
|
||||
session.User.Username = "Matt"
|
||||
|
||||
result, err := engine.PrepareForYou(
|
||||
context.Background(), emby.Credentials{UserID: "emby-user"}, "Matt",
|
||||
[]tracearr.Session{session},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareForYou: %v", err)
|
||||
}
|
||||
if len(result.Candidates) < 30 {
|
||||
t.Fatalf("prepared pool was prematurely row-sized: %d candidates", len(result.Candidates))
|
||||
}
|
||||
foundSpecific := false
|
||||
for _, candidate := range result.Candidates {
|
||||
if strings.Contains(candidate.RecommendationReason, "Because you finished Arrival") {
|
||||
foundSpecific = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundSpecific {
|
||||
t.Fatal("expected a candidate explanation grounded in the completed Tracearr title")
|
||||
}
|
||||
if result.Profile.TracearrUserID != "trace-user" || len(result.Mappings) != 1 {
|
||||
t.Fatalf("profile/mapping = %+v / %+v", result.Profile, result.Mappings)
|
||||
}
|
||||
}
|
||||
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func TestStableEvidenceIndexDistributesCandidatesAcrossCompletedTitles(t *testing.T) {
|
||||
seen := map[int]bool{}
|
||||
for i := 0; i < 20; i++ {
|
||||
seen[stableEvidenceIndex("candidate-"+strconv.Itoa(i), 3)] = true
|
||||
}
|
||||
if len(seen) != 3 {
|
||||
t.Fatalf("evidence indices = %+v, want all three sources represented", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRowsQueriesTheProfilesTopGenres(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
@@ -179,6 +392,123 @@ func TestBuildRowsExcludesAlreadyWatchedFromSimilarRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRowsExcludesPlayedTitlesBeyondTheOldHistoryWindow(t *testing.T) {
|
||||
played := make([]json.RawMessage, 0, 61)
|
||||
for i := 0; i < 60; i++ {
|
||||
played = append(played, raw("history-"+strconv.Itoa(i), "History", "Movie", "Drama"))
|
||||
}
|
||||
played = append(played, raw("old-watched", "Old Watched", "Movie", "Drama"))
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{"IsPlayed": played},
|
||||
similar: map[string][]json.RawMessage{},
|
||||
}
|
||||
engine := testEngine(source)
|
||||
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
|
||||
raw("old-watched", "Old Watched", "Movie", "Drama"),
|
||||
raw("new-pick", "New Pick", "Movie", "Drama"),
|
||||
}}
|
||||
engine.MinRowItems = 1
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRows: %v", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for _, candidate := range row.Items {
|
||||
if strings.Contains(string(candidate), `"Id":"old-watched"`) {
|
||||
t.Fatalf("row %q retained a title older than the previous 60-item exclusion window", row.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForYouExcludesTracearrCompletedTitleMissingFromEmbyHistory(t *testing.T) {
|
||||
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsPlayed": {raw("taste", "Taste", "Movie", "Science Fiction")},
|
||||
}}
|
||||
engine := testEngine(source)
|
||||
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
|
||||
json.RawMessage(`{"Id":"watched-copy","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"]}`),
|
||||
raw("unseen", "Moon", "Movie", "Science Fiction"),
|
||||
}}
|
||||
year := 2016
|
||||
session := tracearr.Session{
|
||||
MediaType: "movie", MediaTitle: "Arrival", Year: &year, Watched: true,
|
||||
}
|
||||
engine.Tracearr = fakeTracearr{sessions: []tracearr.Session{session}}
|
||||
engine.MinRowItems = 1
|
||||
|
||||
rows, err := engine.BuildForYou(
|
||||
context.Background(), emby.Credentials{UserID: "u1"}, "FamilyTV", ForYouOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildForYou: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || len(rows[0].Items) != 1 ||
|
||||
!strings.Contains(string(rows[0].Items[0]), `"Id":"unseen"`) {
|
||||
t.Fatalf("Tracearr-completed title was not excluded: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) {
|
||||
profile := Profile{
|
||||
GenreWeights: map[string]float64{"Drama": 2, "Science Fiction": 1},
|
||||
Seen: map[string]bool{}, SeenTitles: map[string]bool{},
|
||||
}
|
||||
evidence := map[string][]PreparedEvidence{
|
||||
"drama": {{
|
||||
ItemID: "arrival", Title: "Arrival",
|
||||
Genres: []string{"Drama", "Science Fiction"},
|
||||
}},
|
||||
}
|
||||
counts := map[string]int{}
|
||||
kinds := map[string]int{}
|
||||
for i := 0; i < 20; i++ {
|
||||
candidate := item(
|
||||
"candidate-"+strconv.Itoa(i), "Candidate", "Movie",
|
||||
[]string{"Drama", "Science Fiction"}, 7,
|
||||
)
|
||||
_, _, kind, _, _ := explainPreparedRecommendation(
|
||||
profile, candidate, compatibilityProfile{}, false, evidence, counts,
|
||||
)
|
||||
kinds[kind]++
|
||||
}
|
||||
if kinds["completed-title"] == 0 || kinds["completed-title"] > 4 {
|
||||
t.Fatalf("completed-title reasons = %d, want 1..4", kinds["completed-title"])
|
||||
}
|
||||
if kinds["genre"] == 0 {
|
||||
t.Fatalf("reason kinds were not mixed: %+v", kinds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedExplanationRejectsOneBroadGenreAsSpecificEvidence(t *testing.T) {
|
||||
profile := Profile{
|
||||
GenreWeights: map[string]float64{"Drama": 2},
|
||||
Seen: map[string]bool{}, SeenTitles: map[string]bool{},
|
||||
}
|
||||
evidence := map[string][]PreparedEvidence{
|
||||
"drama": {{ItemID: "source", Title: "Source", Genres: []string{"Drama"}}},
|
||||
}
|
||||
_, _, kind, _, _ := explainPreparedRecommendation(
|
||||
profile, item("candidate", "Candidate", "Movie", []string{"Drama"}, 7),
|
||||
compatibilityProfile{}, false, evidence, map[string]int{},
|
||||
)
|
||||
if kind != "genre" {
|
||||
t.Fatalf("one broad shared genre produced %q, want genre", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationSessionsDiscardPrerolls(t *testing.T) {
|
||||
sessions := []tracearr.Session{
|
||||
{MediaTitle: "PreRoll_Swirls"},
|
||||
{MediaTitle: "A Real Film"},
|
||||
}
|
||||
got := recommendationSessions(sessions)
|
||||
if len(got) != 1 || got[0].MediaTitle != "A Real Film" {
|
||||
t.Fatalf("recommendation sessions = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
@@ -256,6 +586,130 @@ func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCuratedRowsIncludePersonalizedShowGenres(t *testing.T) {
|
||||
engine := NewEngine(&fakeSource{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
got := map[string]string{}
|
||||
for _, row := range engine.CuratedRows {
|
||||
got[row.ID] = row.Title
|
||||
}
|
||||
|
||||
for id, title := range map[string]string{
|
||||
"curated:comedy-shows": "Comedy Shows",
|
||||
"curated:drama-shows": "Drama Shows",
|
||||
"curated:horror-shows": "Horror Shows",
|
||||
} {
|
||||
if got[id] != title {
|
||||
t.Fatalf("%s title = %q, want %q", id, got[id], title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCuratedRowsIncludeMovieGenresAndStudioFamilies(t *testing.T) {
|
||||
engine := NewEngine(&fakeSource{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
got := map[string]CuratedRow{}
|
||||
for _, row := range engine.CuratedRows {
|
||||
got[row.ID] = row
|
||||
}
|
||||
|
||||
for _, id := range []string{
|
||||
"curated:movies:genre:science-fiction",
|
||||
"curated:movies:genre:animation",
|
||||
"curated:movies:studio:pixar",
|
||||
"curated:movies:studio:disney",
|
||||
} {
|
||||
row, ok := got[id]
|
||||
if !ok {
|
||||
t.Fatalf("missing default movie shelf %q", id)
|
||||
}
|
||||
if row.Kind != "movies" || !row.RequireAffinity {
|
||||
t.Fatalf("movie shelf %q = %+v", id, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovieShelvesRequireAffinityAndRankAStudioBeforeItsGenre(t *testing.T) {
|
||||
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsPlayed": {
|
||||
json.RawMessage(`{"Id":"watched","Name":"Toy Story","Type":"Movie","Genres":["Comedy"],"Studios":[{"Name":"Pixar Animation Studios"}]}`),
|
||||
},
|
||||
}}
|
||||
engine := testEngine(source)
|
||||
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
|
||||
"Comedy": {
|
||||
raw("comedy-1", "Comedy One", "Movie", "Comedy"),
|
||||
raw("comedy-2", "Comedy Two", "Movie", "Comedy"),
|
||||
},
|
||||
"Drama": {
|
||||
raw("drama-1", "Drama One", "Movie", "Drama"),
|
||||
raw("drama-2", "Drama Two", "Movie", "Drama"),
|
||||
},
|
||||
"studio:Pixar": {
|
||||
raw("pixar-1", "Pixar One", "Movie", "Animation"),
|
||||
raw("pixar-2", "Pixar Two", "Movie", "Animation"),
|
||||
},
|
||||
}}
|
||||
engine.CuratedRows = []CuratedRow{
|
||||
movieGenreRow("comedy", "Comedy"),
|
||||
movieGenreRow("drama", "Drama"),
|
||||
movieStudioRow("pixar", "Pixar", "Pixar", "Pixar Animation Studios"),
|
||||
}
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
curated := make([]Row, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if strings.HasPrefix(row.ID, "curated:movies:") {
|
||||
curated = append(curated, row)
|
||||
}
|
||||
}
|
||||
if len(curated) != 2 {
|
||||
t.Fatalf("movie shelves = %+v, want Pixar and Comedy only", rowTitles(curated))
|
||||
}
|
||||
if curated[0].ID != "curated:movies:studio:pixar" ||
|
||||
curated[1].ID != "curated:movies:genre:comedy" {
|
||||
t.Fatalf("movie shelf order = %+v", rowTitles(curated))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCuratedRowsDoNotRepeatCardsAcrossGenres(t *testing.T) {
|
||||
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsPlayed": {raw("history", "Funny", "Episode", "Comedy")},
|
||||
}}
|
||||
engine := testEngine(source)
|
||||
engine.MinRowItems = 1
|
||||
engine.RowSize = 3
|
||||
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
|
||||
"Comedy": {
|
||||
raw("shared", "Shared Show", "Series", "Comedy", "Drama"),
|
||||
raw("comedy", "Comedy Only", "Series", "Comedy"),
|
||||
},
|
||||
"Drama": {
|
||||
raw("shared", "Shared Show", "Series", "Comedy", "Drama"),
|
||||
raw("drama", "Drama Only", "Series", "Drama"),
|
||||
},
|
||||
}}
|
||||
engine.CuratedRows = []CuratedRow{
|
||||
{ID: "comedy", Title: "Comedy Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}},
|
||||
{ID: "drama", Title: "Drama Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}},
|
||||
}
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]int{}
|
||||
for _, row := range rows {
|
||||
for _, item := range Decode(row.Items) {
|
||||
ids[item.ID]++
|
||||
}
|
||||
}
|
||||
if ids["shared"] != 1 {
|
||||
t.Fatalf("shared card appeared %d times across curated rows", ids["shared"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
|
||||
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
|
||||
engine := testEngine(source)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -59,6 +59,23 @@ func TestBuildProfileDeduplicatesSeeds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileDoesNotCountEveryEpisodeAsAnotherTasteVote(t *testing.T) {
|
||||
history := []Item{
|
||||
episode("ep2", "Second", "series", "Series", []string{"Drama"}),
|
||||
episode("ep1", "First", "series", "Series", []string{"Drama"}),
|
||||
item("movie", "Movie", "Movie", []string{"Comedy"}, 0),
|
||||
}
|
||||
profile := BuildProfile(history, nil)
|
||||
|
||||
if profile.GenreWeights["Drama"] != 1 {
|
||||
t.Fatalf("repeated series weight = %v, want the newest occurrence only",
|
||||
profile.GenreWeights["Drama"])
|
||||
}
|
||||
if !profile.Seen["ep1"] || !profile.Seen["ep2"] || !profile.Seen["series"] {
|
||||
t.Fatalf("episode/series exclusions were lost: %+v", profile.Seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoritesContributeLessThanAFreshPlay(t *testing.T) {
|
||||
fromHistory := BuildProfile([]Item{item("1", "A", "Movie", []string{"Horror"}, 0)}, nil)
|
||||
fromFavorite := BuildProfile(nil, []Item{item("2", "B", "Movie", []string{"Horror"}, 0)})
|
||||
|
||||
Reference in New Issue
Block a user