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:
ponzischeme89
2026-08-07 10:44:17 +12:00
co-authored by Claude Opus 5
parent 4a4df7a73c
commit 80c304d86b
62 changed files with 6095 additions and 255 deletions
+74 -10
View File
@@ -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,