Server changes/Sonarr
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -33,6 +34,27 @@ type LibrarySource interface {
|
||||
LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
// CuratedLibrarySource is the optional richer catalogue query used for server-authored
|
||||
// collections. Keeping it separate preserves compatibility with simpler test sources.
|
||||
type CuratedLibrarySource interface {
|
||||
CuratedCandidates(
|
||||
ctx context.Context,
|
||||
itemTypes, genres, studios []string,
|
||||
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
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
source Source
|
||||
log *slog.Logger
|
||||
@@ -47,6 +69,7 @@ type Engine struct {
|
||||
// screen rather than a wall of near-duplicates.
|
||||
MaxSimilarRows int
|
||||
RowSize int
|
||||
CuratedRows []CuratedRow
|
||||
}
|
||||
|
||||
func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
@@ -56,6 +79,29 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
MinRowItems: 4,
|
||||
MaxSimilarRows: 2,
|
||||
RowSize: 20,
|
||||
CuratedRows: []CuratedRow{
|
||||
{
|
||||
ID: "curated:apple-tv",
|
||||
Title: "Apple TV+ Shows",
|
||||
Kind: "shows",
|
||||
ItemTypes: []string{"Series"},
|
||||
Studios: []string{"Apple TV+", "Apple TV Plus", "Apple Studios"},
|
||||
},
|
||||
{
|
||||
ID: "curated:drama-shows",
|
||||
Title: "Drama TV Shows",
|
||||
Kind: "shows",
|
||||
ItemTypes: []string{"Series"},
|
||||
Genres: []string{"Drama"},
|
||||
},
|
||||
{
|
||||
ID: "curated:comedy-shows",
|
||||
Title: "Comedy TV Shows",
|
||||
Kind: "shows",
|
||||
ItemTypes: []string{"Series"},
|
||||
Genres: []string{"Comedy"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,25 +122,77 @@ func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, e
|
||||
}
|
||||
|
||||
profile := BuildProfile(history, favorites)
|
||||
if profile.IsEmpty() {
|
||||
// A brand-new user has nothing to recommend from. No rows is the honest answer.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows := make([]Row, 0, e.MaxSimilarRows+1)
|
||||
for _, seed := range e.seedsFor(profile) {
|
||||
row, ok := e.similarRow(ctx, cred, profile, seed)
|
||||
if ok {
|
||||
rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
|
||||
if !profile.IsEmpty() {
|
||||
for _, seed := range e.seedsFor(profile) {
|
||||
row, ok := e.similarRow(ctx, cred, profile, seed)
|
||||
if ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
if row, ok := e.historyRow(ctx, cred, profile); ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
if row, ok := e.historyRow(ctx, cred, profile); ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
rows = append(rows, e.buildCuratedRows(ctx, profile)...)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
library, ok := e.Library.(CuratedLibrarySource)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
type rankedDefinition struct {
|
||||
definition CuratedRow
|
||||
affinity float64
|
||||
order int
|
||||
}
|
||||
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
|
||||
for order, definition := range e.CuratedRows {
|
||||
definitions = append(definitions, rankedDefinition{
|
||||
definition: definition,
|
||||
affinity: profile.CollectionAffinity(definition.Genres, definition.Studios),
|
||||
order: order,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(definitions, func(i, j int) bool {
|
||||
if definitions[i].affinity != definitions[j].affinity {
|
||||
return definitions[i].affinity > definitions[j].affinity
|
||||
}
|
||||
return definitions[i].order < definitions[j].order
|
||||
})
|
||||
|
||||
rows := make([]Row, 0, len(definitions))
|
||||
for _, ranked := range definitions {
|
||||
definition := ranked.definition
|
||||
raws, err := library.CuratedCandidates(
|
||||
ctx,
|
||||
definition.ItemTypes,
|
||||
definition.Genres,
|
||||
definition.Studios,
|
||||
e.RowSize*6,
|
||||
)
|
||||
if err != nil {
|
||||
e.log.Warn("curated row failed", "row", definition.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
items := RankCollection(profile, Decode(raws), e.RowSize)
|
||||
if len(items) < e.MinRowItems {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, Row{
|
||||
ID: definition.ID,
|
||||
Title: definition.Title,
|
||||
Kind: definition.Kind,
|
||||
Items: Raws(items),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// 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 (
|
||||
|
||||
@@ -27,6 +27,31 @@ type fakeSource struct {
|
||||
similarSeeds []string
|
||||
}
|
||||
|
||||
type fakeCuratedLibrary struct {
|
||||
byGenre map[string][]json.RawMessage
|
||||
}
|
||||
|
||||
func (f *fakeCuratedLibrary) LibraryCandidates(
|
||||
_ context.Context,
|
||||
_ []string,
|
||||
_ int,
|
||||
) ([]json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeCuratedLibrary) CuratedCandidates(
|
||||
_ context.Context,
|
||||
_ []string,
|
||||
genres, studios []string,
|
||||
_ int,
|
||||
) ([]json.RawMessage, error) {
|
||||
key := strings.Join(genres, "|")
|
||||
if len(studios) > 0 {
|
||||
key = "studio:" + studios[0]
|
||||
}
|
||||
return f.byGenre[key], nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Values) (*emby.ItemsResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -191,6 +216,67 @@ func TestBuildRowsReturnsNothingForAUserWithNoHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsPlayed": {
|
||||
raw("history", "Funny History", "Episode", "Comedy"),
|
||||
},
|
||||
},
|
||||
similar: map[string][]json.RawMessage{},
|
||||
}
|
||||
engine := testEngine(source)
|
||||
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
|
||||
"Comedy": {
|
||||
raw("comedy-low", "Lower Rated Match", "Series", "Comedy"),
|
||||
raw("comedy-high", "Higher Rated Match", "Series", "Comedy"),
|
||||
},
|
||||
"Drama": {
|
||||
raw("drama-1", "Drama One", "Series", "Drama"),
|
||||
raw("drama-2", "Drama Two", "Series", "Drama"),
|
||||
},
|
||||
}}
|
||||
engine.CuratedRows = []CuratedRow{
|
||||
{ID: "drama", Title: "Drama TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}},
|
||||
{ID: "comedy", Title: "Comedy TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}},
|
||||
}
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected two curated rows, got %v", rowTitles(rows))
|
||||
}
|
||||
if rows[0].ID != "comedy" || rows[1].ID != "drama" {
|
||||
t.Fatalf("user's comedy affinity should order shelves, got %v", rowTitles(rows))
|
||||
}
|
||||
if rows[0].Kind != "shows" {
|
||||
t.Fatalf("curated TV shelf kind = %q", rows[0].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
|
||||
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
|
||||
engine := testEngine(source)
|
||||
engine.MinRowItems = 1
|
||||
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
|
||||
"Drama": {raw("drama", "Strong Drama", "Series", "Drama")},
|
||||
}}
|
||||
engine.CuratedRows = []CuratedRow{{
|
||||
ID: "drama", Title: "Drama TV Shows", Kind: "shows",
|
||||
ItemTypes: []string{"Series"}, Genres: []string{"Drama"},
|
||||
}}
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ID != "drama" {
|
||||
t.Fatalf("new profiles should receive quality-ranked curated rows: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// A failing similarity lookup is one dead row, not a dead home screen.
|
||||
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
|
||||
@@ -193,8 +193,39 @@ func (p Profile) Score(candidate Item) float64 {
|
||||
return genreScore + studioScore + ratingScore
|
||||
}
|
||||
|
||||
// CollectionAffinity decides which curated shelf appears first for this user.
|
||||
func (p Profile) CollectionAffinity(genres, studios []string) float64 {
|
||||
var score float64
|
||||
for _, genre := range genres {
|
||||
score += weightFold(p.GenreWeights, genre)
|
||||
}
|
||||
for _, studio := range studios {
|
||||
score += weightFold(p.StudioWeights, studio)
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func weightFold(weights map[string]float64, wanted string) float64 {
|
||||
for key, value := range weights {
|
||||
if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(wanted)) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Rank scores, filters and truncates candidates, dropping duplicates by id.
|
||||
func Rank(profile Profile, candidates []Item, limit int) []Item {
|
||||
return rank(profile, candidates, limit, false)
|
||||
}
|
||||
|
||||
// RankCollection keeps unseen candidates with no affinity/rating score at the end,
|
||||
// ensuring a curated shelf remains useful for a new profile or unrated library.
|
||||
func RankCollection(profile Profile, candidates []Item, limit int) []Item {
|
||||
return rank(profile, candidates, limit, true)
|
||||
}
|
||||
|
||||
func rank(profile Profile, candidates []Item, limit int, includeZero bool) []Item {
|
||||
type scored struct {
|
||||
item Item
|
||||
score float64
|
||||
@@ -207,7 +238,7 @@ func Rank(profile Profile, candidates []Item, limit int) []Item {
|
||||
continue
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
if score := profile.Score(candidate); score > 0 {
|
||||
if score := profile.Score(candidate); score > 0 || includeZero && score == 0 {
|
||||
ranked = append(ranked, scored{candidate, score})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,16 @@ func TestRankRespectsLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionAffinityIsCaseInsensitive(t *testing.T) {
|
||||
profile := Profile{
|
||||
GenreWeights: map[string]float64{"Comedy": 2},
|
||||
StudioWeights: map[string]float64{"Apple TV+": 0.5},
|
||||
}
|
||||
if got := profile.CollectionAffinity([]string{"comedy"}, []string{"apple tv+"}); got != 2.5 {
|
||||
t.Fatalf("CollectionAffinity = %v, want 2.5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeKeepsRawPayload(t *testing.T) {
|
||||
raw := json.RawMessage(`{"Id":"1","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"ImageTags":{"Primary":"abc"}}`)
|
||||
items := Decode([]json.RawMessage{raw, json.RawMessage(`{"broken":`), json.RawMessage(`{"Name":"no id"}`)})
|
||||
|
||||
Reference in New Issue
Block a user