App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -982,25 +982,60 @@ func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile
|
||||
// in the library is like it.
|
||||
//
|
||||
// Both halves come from the same profile the home rows are built from, so the page can
|
||||
// never explain a taste the engine does not hold. A failed Similar lookup still returns
|
||||
// the reasons — the strip under the description is worth more than the carousel.
|
||||
// never explain a taste the engine does not hold. Every part of it degrades rather than
|
||||
// fails: the only error this returns is the viewer having gone away, because a detail
|
||||
// page that cannot explain itself still has to open, and a carousel is worth having
|
||||
// without a reason strip above it.
|
||||
func (e *Engine) RelatedTo(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
item Item,
|
||||
limit int,
|
||||
) (reasons []string, related []Item, err error) {
|
||||
history, favorites, err := e.gatherSignals(ctx, cred)
|
||||
if err != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
profile := BuildProfile(history, favorites)
|
||||
reasons = Why(profile, item, ReasonLimit)
|
||||
|
||||
if limit <= 0 {
|
||||
limit = e.RowSize
|
||||
}
|
||||
result, similarErr := e.source.Similar(ctx, cred, item.ID, url.Values{
|
||||
|
||||
// The profile is the expensive half and the optional one: it costs the same Emby
|
||||
// fan-out the home rows pay for, and everything below still works without it. Why
|
||||
// falls back to catalogue facts and FilterUnseen keeps everything, so a household
|
||||
// whose history request timed out gets a carousel instead of a failed page.
|
||||
var profile Profile
|
||||
history, favorites, signalErr := e.gatherSignals(ctx, cred)
|
||||
switch {
|
||||
case signalErr != nil && ctx.Err() != nil:
|
||||
return nil, nil, ctx.Err()
|
||||
case signalErr != nil:
|
||||
e.log.Warn(
|
||||
"related profile unavailable; answering without one",
|
||||
"item", item.ID, "error", signalErr,
|
||||
)
|
||||
default:
|
||||
profile = BuildProfile(history, favorites)
|
||||
}
|
||||
reasons = Why(profile, item, ReasonLimit)
|
||||
|
||||
return reasons, e.relatedCandidates(ctx, cred, profile, item, limit), nil
|
||||
}
|
||||
|
||||
// relatedCandidates fills the carousel from the best source that answers, in falling
|
||||
// order of quality: Emby's own similarity ranking, then the imported catalogue's titles
|
||||
// in the same genres. The second exists because the first has two failure modes that
|
||||
// look identical on a TV — Emby erroring, and Emby honestly knowing nothing similar
|
||||
// about a title nobody has tagged — and an empty strip on a detail page reads as broken
|
||||
// either way.
|
||||
func (e *Engine) relatedCandidates(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
profile Profile,
|
||||
item Item,
|
||||
limit int,
|
||||
) []Item {
|
||||
var candidates []Item
|
||||
result, err := e.source.Similar(ctx, cred, item.ID, url.Values{
|
||||
"UserId": {cred.UserID},
|
||||
"Limit": {strconv.Itoa(limit * 2)},
|
||||
"Fields": {candidateFields},
|
||||
@@ -1009,19 +1044,60 @@ func (e *Engine) RelatedTo(
|
||||
"EnableImageTypes": {rowImageTypes},
|
||||
"EnableUserData": {"true"},
|
||||
})
|
||||
if similarErr != nil {
|
||||
e.log.Warn("related lookup failed", "item", item.ID, "error", similarErr)
|
||||
return reasons, nil, nil
|
||||
switch {
|
||||
case err != nil && ctx.Err() != nil:
|
||||
return nil
|
||||
case err != nil:
|
||||
e.log.Warn("related lookup failed", "item", item.ID, "error", err)
|
||||
case result != nil:
|
||||
candidates = excludeItem(Decode(result.Items), item.ID)
|
||||
}
|
||||
|
||||
candidates := Decode(result.Items)
|
||||
related = FilterUnseen(profile, candidates, limit)
|
||||
related := FilterUnseen(profile, candidates, limit)
|
||||
// A carousel of two looks broken. Someone deep into a franchise has seen most of
|
||||
// what resembles it, so fall back to Emby's unfiltered order rather than a stub.
|
||||
if len(related) < e.MinRowItems && len(candidates) > len(related) {
|
||||
related = trim(candidates, limit)
|
||||
}
|
||||
return reasons, related, nil
|
||||
if len(related) >= e.MinRowItems {
|
||||
return related
|
||||
}
|
||||
if neighbours := e.genreNeighbours(ctx, profile, item, limit); len(neighbours) > len(related) {
|
||||
return neighbours
|
||||
}
|
||||
return related
|
||||
}
|
||||
|
||||
// genreNeighbours is the answer of last resort: the household's own catalogue, in this
|
||||
// title's genres, best rated first. It reads from Postgres rather than Emby, so it is
|
||||
// also the only half of this that still works while Emby is the thing that is down.
|
||||
func (e *Engine) genreNeighbours(ctx context.Context, profile Profile, item Item, limit int) []Item {
|
||||
if e.Library == nil || len(item.Genres) == 0 || ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
raws, err := e.Library.LibraryCandidates(ctx, item.Genres, limit*6)
|
||||
if err != nil {
|
||||
e.log.Warn("related genre fallback failed", "item", item.ID, "error", err)
|
||||
return nil
|
||||
}
|
||||
candidates := excludeItem(Decode(raws), item.ID)
|
||||
if unseen := FilterUnseen(profile, candidates, limit); len(unseen) > 0 {
|
||||
return unseen
|
||||
}
|
||||
return trim(candidates, limit)
|
||||
}
|
||||
|
||||
// excludeItem drops the title the page is about. Emby occasionally returns it in its own
|
||||
// similarity list, and the imported catalogue always does.
|
||||
func excludeItem(items []Item, id string) []Item {
|
||||
out := make([]Item, 0, len(items))
|
||||
for _, candidate := range items {
|
||||
if candidate.ID == id {
|
||||
continue
|
||||
}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trim(items []Item, limit int) []Item {
|
||||
|
||||
Reference in New Issue
Block a user