Publish current app and server
This commit is contained in:
@@ -3,6 +3,7 @@ package recommend
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/url"
|
||||
@@ -54,6 +55,12 @@ type CuratedLibrarySource interface {
|
||||
) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
// GenreLibrarySource lets an imported Sonarr/Radarr/Emby catalogue describe itself.
|
||||
// It is optional so recommendation sources that predate genre discovery still work.
|
||||
type GenreLibrarySource interface {
|
||||
LibraryGenres(ctx context.Context, itemTypes []string, minItems int) ([]string, error)
|
||||
}
|
||||
|
||||
type TracearrSource interface {
|
||||
History(ctx context.Context, username string, limit int) ([]tracearr.Session, error)
|
||||
}
|
||||
@@ -96,6 +103,12 @@ type Engine struct {
|
||||
MaxSimilarRows int
|
||||
RowSize int
|
||||
CuratedRows []CuratedRow
|
||||
WeightedConfig WeightedConfig
|
||||
|
||||
// Location defines the viewer's local day/time windows. Now is injectable so
|
||||
// contextual placement stays deterministic in tests.
|
||||
Location *time.Location
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// ForYouOptions are request-scoped constraints chosen on the television.
|
||||
@@ -125,12 +138,13 @@ func (e *Engine) BuildForYou(
|
||||
profile := BuildProfile(history, favorites)
|
||||
|
||||
var sessions []tracearr.Session
|
||||
contextAffinity := NewContextAffinityProfile()
|
||||
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)
|
||||
contextAffinity = e.applyTracearrSignals(&profile, history, sessions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +177,10 @@ func (e *Engine) BuildForYou(
|
||||
}
|
||||
|
||||
compatibility := buildCompatibilityProfile(sessions)
|
||||
items := rankForYou(profile, candidates, options.AvailableMinutes, compatibility, e.RowSize)
|
||||
items := rankForYouAt(
|
||||
profile, candidates, options.AvailableMinutes, compatibility, e.RowSize,
|
||||
contextAffinity, e.now(), e.Location,
|
||||
)
|
||||
if len(items) < e.MinRowItems {
|
||||
return []Row{}, nil
|
||||
}
|
||||
@@ -199,7 +216,8 @@ func (e *Engine) applyTracearrSignals(
|
||||
profile *Profile,
|
||||
history []Item,
|
||||
sessions []tracearr.Session,
|
||||
) {
|
||||
) ContextAffinityProfile {
|
||||
contextAffinity := NewContextAffinityProfile()
|
||||
byTitle := make(map[string]Item, len(history))
|
||||
for _, item := range history {
|
||||
byTitle[item.TitleKey()] = item
|
||||
@@ -216,7 +234,11 @@ func (e *Engine) applyTracearrSignals(
|
||||
// minutes"; recency lets changing tastes move promptly.
|
||||
weight := (0.2 + session.Completion()) * powDecay(0.985, i)
|
||||
profile.absorbTaste(item, weight)
|
||||
if started, ok := parseTracearrTime(session.StartedAt); ok {
|
||||
contextAffinity.Add(item, started, session.Completion(), i, e.Location)
|
||||
}
|
||||
}
|
||||
return contextAffinity
|
||||
}
|
||||
|
||||
func (e *Engine) libraryCandidatesForYou(
|
||||
@@ -307,13 +329,32 @@ func rankForYou(
|
||||
availableMinutes int,
|
||||
compatibility compatibilityProfile,
|
||||
limit int,
|
||||
) []Item {
|
||||
return rankForYouAt(
|
||||
profile, candidates, availableMinutes, compatibility, limit,
|
||||
ContextAffinityProfile{}, time.Time{}, nil,
|
||||
)
|
||||
}
|
||||
|
||||
func rankForYouAt(
|
||||
profile Profile,
|
||||
candidates []Item,
|
||||
availableMinutes int,
|
||||
compatibility compatibilityProfile,
|
||||
limit int,
|
||||
contextAffinity ContextAffinityProfile,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
) []Item {
|
||||
type scored struct {
|
||||
item Item
|
||||
score float64
|
||||
item Item
|
||||
score float64
|
||||
contextRaw float64
|
||||
confidence float64
|
||||
}
|
||||
ranked := make([]scored, 0, len(candidates))
|
||||
seen := map[string]bool{}
|
||||
var maxContext float64
|
||||
for _, candidate := range candidates {
|
||||
if seen[candidate.ID] {
|
||||
continue
|
||||
@@ -333,7 +374,20 @@ func rankForYou(
|
||||
// allowing runtime to overwhelm taste.
|
||||
score += float64(runtime) / float64(availableMinutes) * 0.35
|
||||
}
|
||||
ranked = append(ranked, scored{item: candidate, score: score})
|
||||
contextRaw, confidence := contextAffinity.Score(candidate, now, location)
|
||||
if contextRaw > maxContext {
|
||||
maxContext = contextRaw
|
||||
}
|
||||
ranked = append(ranked, scored{
|
||||
item: candidate, score: score, contextRaw: contextRaw, confidence: confidence,
|
||||
})
|
||||
}
|
||||
if maxContext > 0 {
|
||||
for i := range ranked {
|
||||
// A maximum 1.5-point lift is enough to rearrange similarly relevant
|
||||
// posters without allowing a routine to overpower taste or quality.
|
||||
ranked[i].score += 1.5 * ranked[i].confidence * ranked[i].contextRaw / maxContext
|
||||
}
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].score != ranked[j].score {
|
||||
@@ -351,6 +405,13 @@ func rankForYou(
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Engine) now() time.Time {
|
||||
if e.Now != nil {
|
||||
return e.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func explainRecommendation(
|
||||
profile Profile,
|
||||
item Item,
|
||||
@@ -432,6 +493,7 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
MinRowItems: 4,
|
||||
MaxSimilarRows: 2,
|
||||
RowSize: 20,
|
||||
WeightedConfig: DefaultWeightedConfig(),
|
||||
CuratedRows: []CuratedRow{
|
||||
{
|
||||
ID: "curated:apple-tv",
|
||||
@@ -518,8 +580,8 @@ func movieStudioRow(id, title string, studios ...string) CuratedRow {
|
||||
}
|
||||
|
||||
const (
|
||||
historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks"
|
||||
candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
|
||||
historyFields = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,SeriesName,ProductionYear,PremiereDate,RunTimeTicks"
|
||||
candidateFields = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
|
||||
rowImageTypes = "Backdrop,Primary,Logo"
|
||||
)
|
||||
|
||||
@@ -528,12 +590,45 @@ const (
|
||||
// Cost is a handful of Emby queries, which is why callers cache the result rather than
|
||||
// computing it on every home load.
|
||||
func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, error) {
|
||||
return e.BuildRowsForUser(ctx, cred, "")
|
||||
}
|
||||
|
||||
// BuildRowsForUser combines Emby state with Tracearr playback outcomes, Memby's own
|
||||
// browsing events and the imported Sonarr/Radarr-backed catalogue. BuildRows remains as
|
||||
// the compatibility entry point for tests and callers that do not know a username.
|
||||
func (e *Engine) BuildRowsForUser(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
username string,
|
||||
) ([]Row, error) {
|
||||
history, favorites, err := e.gatherSignals(ctx, cred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profile := BuildProfile(history, favorites)
|
||||
contextAffinity := NewContextAffinityProfile()
|
||||
if e.Tracearr != nil && strings.TrimSpace(username) != "" {
|
||||
if sessions, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
|
||||
e.log.Warn("tracearr signals unavailable for shelves", "error", traceErr)
|
||||
} else {
|
||||
contextAffinity = e.applyTracearrSignals(
|
||||
&profile, history, recommendationSessions(sessions),
|
||||
)
|
||||
}
|
||||
}
|
||||
if e.Behavior != nil {
|
||||
if raws, browseErr := e.Behavior.BrowsingCandidates(
|
||||
ctx, cred.UserID, time.Now().Add(-30*24*time.Hour), 40,
|
||||
); browseErr != nil {
|
||||
e.log.Warn("browsing signals unavailable for shelves", "error", browseErr)
|
||||
} else {
|
||||
for i, item := range Decode(raws) {
|
||||
profile.absorbTaste(item, 0.55*powDecay(0.92, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
profile = contextAffinity.Contextualized(profile, e.now(), e.Location)
|
||||
|
||||
rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
|
||||
if !profile.IsEmpty() {
|
||||
@@ -548,22 +643,26 @@ func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, e
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
rows = append(rows, e.buildCuratedRows(ctx, profile)...)
|
||||
rows = append(rows, e.buildCuratedRows(ctx, profile, dailySeed(cred.UserID))...)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile, seed string) []Row {
|
||||
library, ok := e.Library.(CuratedLibrarySource)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
curatedDefinitions := append([]CuratedRow(nil), e.CuratedRows...)
|
||||
if genres, supportsGenres := e.Library.(GenreLibrarySource); supportsGenres {
|
||||
curatedDefinitions = e.catalogueGenreRows(ctx, genres, curatedDefinitions)
|
||||
}
|
||||
type rankedDefinition struct {
|
||||
definition CuratedRow
|
||||
affinity float64
|
||||
order int
|
||||
}
|
||||
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
|
||||
for order, definition := range e.CuratedRows {
|
||||
definitions := make([]rankedDefinition, 0, len(curatedDefinitions))
|
||||
for order, definition := range curatedDefinitions {
|
||||
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
|
||||
@@ -585,6 +684,11 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
if definitions[i].affinity != definitions[j].affinity {
|
||||
return definitions[i].affinity > definitions[j].affinity
|
||||
}
|
||||
left := stableVariation(seed, definitions[i].definition.ID)
|
||||
right := stableVariation(seed, definitions[j].definition.ID)
|
||||
if left != right {
|
||||
return left < right
|
||||
}
|
||||
return definitions[i].order < definitions[j].order
|
||||
})
|
||||
|
||||
@@ -592,20 +696,8 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
// 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,
|
||||
@@ -618,6 +710,7 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
continue
|
||||
}
|
||||
rankedItems := RankCollection(profile, Decode(raws), e.RowSize*2)
|
||||
rankedItems = diversifyRanked(rankedItems, seed+":"+definition.ID, 5)
|
||||
items := make([]Item, 0, e.RowSize)
|
||||
for _, item := range rankedItems {
|
||||
if _, seen := seenItems[item.ID]; seen {
|
||||
@@ -640,16 +733,106 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
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
|
||||
}
|
||||
|
||||
func (e *Engine) catalogueGenreRows(
|
||||
ctx context.Context,
|
||||
source GenreLibrarySource,
|
||||
definitions []CuratedRow,
|
||||
) []CuratedRow {
|
||||
// Ask for extra depth because watched titles and cross-shelf deduplication reduce
|
||||
// the usable pool. A genre is emitted only if MinRowItems unseen cards survive.
|
||||
minimumDepth := e.MinRowItems * 2
|
||||
for _, itemType := range []string{"Series", "Movie"} {
|
||||
genres, err := source.LibraryGenres(ctx, []string{itemType}, minimumDepth)
|
||||
if err != nil {
|
||||
e.log.Warn("library genre discovery failed", "type", itemType, "error", err)
|
||||
continue
|
||||
}
|
||||
for _, genre := range genres {
|
||||
idPrefix, titleSuffix, kind := "curated:shows:genre:", " Shows", "shows"
|
||||
if itemType == "Movie" {
|
||||
idPrefix, titleSuffix, kind = "curated:movies:genre:", " Movies", "movies"
|
||||
}
|
||||
id := idPrefix + rowSlug(genre)
|
||||
replaced := false
|
||||
for index := range definitions {
|
||||
existing := &definitions[index]
|
||||
if existing.Kind == kind && len(existing.Genres) == 1 &&
|
||||
strings.EqualFold(existing.Genres[0], genre) {
|
||||
// Catalogue depth proves this can be a useful shelf even when it
|
||||
// is outside the viewer's established affinity.
|
||||
existing.RequireAffinity = false
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
definitions = append(definitions, CuratedRow{
|
||||
ID: id, Title: genre + titleSuffix, Kind: kind,
|
||||
ItemTypes: []string{itemType}, Genres: []string{genre},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return definitions
|
||||
}
|
||||
|
||||
func rowSlug(value string) string {
|
||||
var out strings.Builder
|
||||
dash := false
|
||||
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||
if dash && out.Len() > 0 {
|
||||
out.WriteByte('-')
|
||||
}
|
||||
out.WriteRune(r)
|
||||
dash = false
|
||||
} else {
|
||||
dash = true
|
||||
}
|
||||
}
|
||||
if out.Len() > 0 {
|
||||
return out.String()
|
||||
}
|
||||
return strconv.FormatUint(stableVariation("", value), 36)
|
||||
}
|
||||
|
||||
func dailySeed(userID string) string {
|
||||
return userID + ":" + time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func stableVariation(seed, value string) uint64 {
|
||||
hash := fnv.New64a()
|
||||
_, _ = hash.Write([]byte(seed))
|
||||
_, _ = hash.Write([]byte{0})
|
||||
_, _ = hash.Write([]byte(value))
|
||||
return hash.Sum64()
|
||||
}
|
||||
|
||||
// diversifyRanked preserves relevance bands while changing the exact poster sequence
|
||||
// per user and day. Affinity/rating still choose each five-card band; variation only
|
||||
// decides which equally strong-looking option catches the eye first.
|
||||
func diversifyRanked(items []Item, seed string, bandSize int) []Item {
|
||||
if bandSize < 2 || len(items) < 2 {
|
||||
return items
|
||||
}
|
||||
out := append([]Item(nil), items...)
|
||||
for start := 0; start < len(out); start += bandSize {
|
||||
end := start + bandSize
|
||||
if end > len(out) {
|
||||
end = len(out)
|
||||
}
|
||||
sort.SliceStable(out[start:end], func(i, j int) bool {
|
||||
return stableVariation(seed, out[start+i].ID) <
|
||||
stableVariation(seed, out[start+j].ID)
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gatherSignals reads what the user has watched and favourited, in parallel.
|
||||
func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) {
|
||||
var (
|
||||
@@ -795,6 +978,59 @@ func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile
|
||||
}, true
|
||||
}
|
||||
|
||||
// RelatedTo answers a detail page: why this viewer might enjoy one title, and what else
|
||||
// in the library is like it.
|
||||
//
|
||||
// Both halves come from the same profile the home rows are built from, so the page can
|
||||
// never explain a taste the engine does not hold. A failed Similar lookup still returns
|
||||
// the reasons — the strip under the description is worth more than the carousel.
|
||||
func (e *Engine) RelatedTo(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
item Item,
|
||||
limit int,
|
||||
) (reasons []string, related []Item, err error) {
|
||||
history, favorites, err := e.gatherSignals(ctx, cred)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
profile := BuildProfile(history, favorites)
|
||||
reasons = Why(profile, item, ReasonLimit)
|
||||
|
||||
if limit <= 0 {
|
||||
limit = e.RowSize
|
||||
}
|
||||
result, similarErr := e.source.Similar(ctx, cred, item.ID, url.Values{
|
||||
"UserId": {cred.UserID},
|
||||
"Limit": {strconv.Itoa(limit * 2)},
|
||||
"Fields": {candidateFields},
|
||||
"ImageTypeLimit": {"1"},
|
||||
"EnableImages": {"true"},
|
||||
"EnableImageTypes": {rowImageTypes},
|
||||
"EnableUserData": {"true"},
|
||||
})
|
||||
if similarErr != nil {
|
||||
e.log.Warn("related lookup failed", "item", item.ID, "error", similarErr)
|
||||
return reasons, nil, nil
|
||||
}
|
||||
|
||||
candidates := Decode(result.Items)
|
||||
related = FilterUnseen(profile, candidates, limit)
|
||||
// A carousel of two looks broken. Someone deep into a franchise has seen most of
|
||||
// what resembles it, so fall back to Emby's unfiltered order rather than a stub.
|
||||
if len(related) < e.MinRowItems && len(candidates) > len(related) {
|
||||
related = trim(candidates, limit)
|
||||
}
|
||||
return reasons, related, nil
|
||||
}
|
||||
|
||||
func trim(items []Item, limit int) []Item {
|
||||
if limit > 0 && len(items) > limit {
|
||||
return items[:limit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// historyRow is the genre-affinity row: unwatched titles from the genres the user has
|
||||
// been spending time in, ranked by how closely they match the whole profile.
|
||||
func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile Profile) (Row, bool) {
|
||||
|
||||
Reference in New Issue
Block a user