Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+266 -30
View File
@@ -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) {