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
+500 -19
View File
@@ -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) {