Server changes/Sonarr

This commit is contained in:
ponzischeme89
2026-07-27 21:06:51 +12:00
parent 62f6345a40
commit 8d6cf2f5a1
42 changed files with 2388 additions and 284 deletions
+110 -12
View File
@@ -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 (
+86
View File
@@ -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{
+32 -1
View File
@@ -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})
}
}
+10
View File
@@ -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"}`)})