App v0.2.27 and gateway 0.1.23
Skip Intro from Emby's own chapter markers, trickplay seek previews from BIF files, a server-composed home hero ranked on Radarr/Sonarr dates and review scores, and My Alerts as its own page behind the user picker. Related titles now degrade at every step instead of returning empty, and the "+" is back on Manage users so a second viewer can be added from the launcher. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4a4df7a73c
commit
80c304d86b
@@ -101,6 +101,11 @@ type Engine struct {
|
||||
// MaxSimilarRows caps "Because you watched …" rows so the home screen stays a home
|
||||
// screen rather than a wall of near-duplicates.
|
||||
MaxSimilarRows int
|
||||
// SeedPool is how far back down the watch history those rows may be anchored. It is
|
||||
// deliberately several times MaxSimilarRows: the rows are drawn from bands of this
|
||||
// window and rotate within them daily, which is what keeps the launcher moving for a
|
||||
// household part-way through one series.
|
||||
SeedPool int
|
||||
RowSize int
|
||||
CuratedRows []CuratedRow
|
||||
WeightedConfig WeightedConfig
|
||||
@@ -491,7 +496,8 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
source: source,
|
||||
log: log,
|
||||
MinRowItems: 4,
|
||||
MaxSimilarRows: 2,
|
||||
MaxSimilarRows: 3,
|
||||
SeedPool: 12,
|
||||
RowSize: 20,
|
||||
WeightedConfig: DefaultWeightedConfig(),
|
||||
CuratedRows: []CuratedRow{
|
||||
@@ -630,10 +636,11 @@ func (e *Engine) BuildRowsForUser(
|
||||
}
|
||||
profile = contextAffinity.Contextualized(profile, e.now(), e.Location)
|
||||
|
||||
variation := dailySeed(cred.UserID)
|
||||
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)
|
||||
for _, seed := range e.seedsFor(profile, variation) {
|
||||
row, ok := e.similarRow(ctx, cred, profile, seed, variation)
|
||||
if ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
@@ -643,7 +650,7 @@ func (e *Engine) BuildRowsForUser(
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
rows = append(rows, e.buildCuratedRows(ctx, profile, dailySeed(cred.UserID))...)
|
||||
rows = append(rows, e.buildCuratedRows(ctx, profile, variation)...)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
@@ -942,16 +949,72 @@ func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item
|
||||
return Decode(raws), true
|
||||
}
|
||||
|
||||
func (e *Engine) seedsFor(profile Profile) []Seed {
|
||||
if len(profile.Seeds) <= e.MaxSimilarRows {
|
||||
return profile.Seeds
|
||||
func (e *Engine) seedsFor(profile Profile, variation string) []Seed {
|
||||
return selectSeeds(profile.Seeds, e.SeedPool, e.MaxSimilarRows, variation)
|
||||
}
|
||||
|
||||
// selectSeeds chooses which watched titles anchor "Because you watched …" rows.
|
||||
//
|
||||
// Taking the freshest few is the obvious rule and is also why the launcher went stale: the
|
||||
// head of history is a resumable title and the series somebody is part-way through, and
|
||||
// neither moves for weeks — so the same two rows came back day after day. Instead the
|
||||
// recent window is cut into equal bands and one seed is drawn from each by a variation
|
||||
// that changes daily. Three properties are the point and are unit-tested:
|
||||
//
|
||||
// - The bands are in recency order, so the first row is still anchored to something
|
||||
// watched lately and the rows under it reach further back. It is a rotation within
|
||||
// bands, never a shuffle of the whole window.
|
||||
// - The same day always yields the same seeds. Rows are rebuilt on every cache miss and
|
||||
// a set of rows that re-picked each time would change under somebody browsing.
|
||||
// - Nothing new has to be watched for the launcher to move on.
|
||||
func selectSeeds(seeds []Seed, pool, max int, variation string) []Seed {
|
||||
if max <= 0 || len(seeds) == 0 {
|
||||
return nil
|
||||
}
|
||||
return profile.Seeds[:e.MaxSimilarRows]
|
||||
if pool < max {
|
||||
pool = max
|
||||
}
|
||||
if pool > len(seeds) {
|
||||
pool = len(seeds)
|
||||
}
|
||||
if pool <= max {
|
||||
return append([]Seed(nil), seeds[:pool]...)
|
||||
}
|
||||
|
||||
band := pool / max
|
||||
out := make([]Seed, 0, max)
|
||||
for index := 0; index < max; index++ {
|
||||
start := index * band
|
||||
end := start + band
|
||||
// The last band takes the remainder, so a pool that does not divide evenly is
|
||||
// still drawn from in full rather than having its oldest entries stranded.
|
||||
if index == max-1 {
|
||||
end = pool
|
||||
}
|
||||
best := start
|
||||
for candidate := start + 1; candidate < end; candidate++ {
|
||||
if stableVariation(variation, seeds[candidate].ID) <
|
||||
stableVariation(variation, seeds[best].ID) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
out = append(out, seeds[best])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// similarRow asks Emby what resembles a title the user just watched. Emby's own
|
||||
// similarity scoring beats anything computed here, so this only filters out the seen.
|
||||
func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile Profile, seed Seed) (Row, bool) {
|
||||
// similarity scoring beats anything computed here, so this only filters out the seen and
|
||||
// varies the order within relevance bands, the way curated shelves do — a row anchored to
|
||||
// the same seed on two consecutive days must not present the same posters in the same
|
||||
// order, or rotating the seeds is the only thing that ever changes.
|
||||
func (e *Engine) similarRow(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
profile Profile,
|
||||
seed Seed,
|
||||
variation string,
|
||||
) (Row, bool) {
|
||||
result, err := e.source.Similar(ctx, cred, seed.ID, url.Values{
|
||||
"UserId": {cred.UserID},
|
||||
"Limit": {strconv.Itoa(e.RowSize * 2)},
|
||||
@@ -970,6 +1033,7 @@ func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile
|
||||
if len(items) < e.MinRowItems {
|
||||
return Row{}, false
|
||||
}
|
||||
items = diversifyRanked(items, variation+":similar:"+seed.ID, 5)
|
||||
return Row{
|
||||
ID: "similar:" + seed.ID,
|
||||
Title: "Because you watched " + seed.Name,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -875,3 +876,93 @@ func rowTitles(rows []Row) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func testSeeds(count int) []Seed {
|
||||
seeds := make([]Seed, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
id := "s" + strconv.Itoa(index)
|
||||
seeds = append(seeds, Seed{ID: id, Name: "Title " + id})
|
||||
}
|
||||
return seeds
|
||||
}
|
||||
|
||||
func seedIDs(seeds []Seed) []string {
|
||||
out := make([]string, 0, len(seeds))
|
||||
for _, seed := range seeds {
|
||||
out = append(out, seed.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The head of a household's history barely moves while they work through one series, so
|
||||
// the seeds must move without it.
|
||||
func TestSelectSeedsRotatesWithoutNewHistory(t *testing.T) {
|
||||
seeds := testSeeds(12)
|
||||
days := map[string]bool{}
|
||||
for day := 1; day <= 14; day++ {
|
||||
chosen := selectSeeds(seeds, 12, 3, "u1:2026-08-"+strconv.Itoa(day))
|
||||
if len(chosen) != 3 {
|
||||
t.Fatalf("day %d chose %d seeds", day, len(chosen))
|
||||
}
|
||||
days[strings.Join(seedIDs(chosen), ",")] = true
|
||||
}
|
||||
if len(days) < 4 {
|
||||
t.Fatalf("a fortnight produced only %d distinct seed sets: %v", len(days), days)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation is within recency bands, never a shuffle of the whole window: the first row is
|
||||
// still anchored to something watched lately.
|
||||
func TestSelectSeedsKeepsRecencyBands(t *testing.T) {
|
||||
seeds := testSeeds(12)
|
||||
for day := 1; day <= 30; day++ {
|
||||
chosen := selectSeeds(seeds, 12, 3, "u1:day-"+strconv.Itoa(day))
|
||||
for index, seed := range chosen {
|
||||
position, err := strconv.Atoi(strings.TrimPrefix(seed.ID, "s"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected seed id %q", seed.ID)
|
||||
}
|
||||
if position < index*4 || position >= (index+1)*4 {
|
||||
t.Fatalf("row %d drew %s from outside its band", index, seed.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rows are rebuilt on every cache miss, so the same day must always yield the same seeds.
|
||||
func TestSelectSeedsIsStableWithinADay(t *testing.T) {
|
||||
seeds := testSeeds(20)
|
||||
first := seedIDs(selectSeeds(seeds, 12, 3, "u1:2026-08-07"))
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
if got := seedIDs(selectSeeds(seeds, 12, 3, "u1:2026-08-07")); !slices.Equal(got, first) {
|
||||
t.Fatalf("same day produced %v then %v", first, got)
|
||||
}
|
||||
}
|
||||
other := seedIDs(selectSeeds(seeds, 12, 3, "u2:2026-08-07"))
|
||||
if slices.Equal(other, first) {
|
||||
t.Log("two users may coincide; only a smoke check")
|
||||
}
|
||||
}
|
||||
|
||||
// A short history has nothing to rotate: take what there is, newest first.
|
||||
func TestSelectSeedsFallsBackToRecencyWhenShort(t *testing.T) {
|
||||
if got := seedIDs(selectSeeds(testSeeds(2), 12, 3, "u1:day")); !slices.Equal(got, []string{"s0", "s1"}) {
|
||||
t.Fatalf("short history = %v", got)
|
||||
}
|
||||
if got := selectSeeds(nil, 12, 3, "u1:day"); len(got) != 0 {
|
||||
t.Fatalf("no history should seed nothing, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A pool that does not divide evenly must not strand its oldest entries.
|
||||
func TestSelectSeedsLastBandTakesTheRemainder(t *testing.T) {
|
||||
seeds := testSeeds(11)
|
||||
reached := map[string]bool{}
|
||||
for day := 1; day <= 40; day++ {
|
||||
chosen := selectSeeds(seeds, 11, 3, "u1:day-"+strconv.Itoa(day))
|
||||
reached[chosen[2].ID] = true
|
||||
}
|
||||
if !reached["s10"] {
|
||||
t.Fatalf("the oldest seed was never reachable: %v", reached)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user