0.2.82
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
"github.com/ponzischeme89/memby/server/internal/timing"
|
||||
)
|
||||
|
||||
// viewerRowLookupLimit bounds one row's worth of ids. A row is a shelf on a television and
|
||||
// nothing draws more than a screenful plus what the D-pad can reach.
|
||||
const viewerRowLookupLimit = 60
|
||||
|
||||
// itemsByID fetches a named set of titles and returns them **in the order asked for**.
|
||||
//
|
||||
// Emby answers an Ids= query in its own order, and for a shadow viewer the order is the
|
||||
// whole answer: Continue Watching is "what am I in the middle of, most recent first", and
|
||||
// that ranking was decided in Postgres out of this viewer's own history. Handing back
|
||||
// Emby's order would keep the right titles and throw away the reason they were chosen.
|
||||
//
|
||||
// The metadata is still Emby's. Only the viewing state belongs to Memby, which is why this
|
||||
// asks for the ordinary row fields and lets decorateItems replace the UserData afterwards.
|
||||
func (s *Server) itemsByID(
|
||||
ctx context.Context, cred emby.Credentials, ids []string, fields string,
|
||||
) (*emby.ItemsResult, error) {
|
||||
if len(ids) == 0 {
|
||||
return &emby.ItemsResult{}, nil
|
||||
}
|
||||
if len(ids) > viewerRowLookupLimit {
|
||||
ids = ids[:viewerRowLookupLimit]
|
||||
}
|
||||
result, err := s.emby.Items(ctx, cred, rowParams(url.Values{
|
||||
"Ids": {strings.Join(ids, ",")},
|
||||
"Recursive": {"true"},
|
||||
"Limit": {itoa(len(ids))},
|
||||
}, fields))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Items = orderItemsByID(result.Items, ids)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// orderItemsByID puts a set of items back into the order they were asked for.
|
||||
//
|
||||
// A title the catalogue still names but Emby no longer answers for is dropped rather than
|
||||
// left as a gap — the row is drawn from what comes back, and a missing card is better than
|
||||
// one that cannot be opened. A title Emby volunteers that was not asked for is dropped too:
|
||||
// the ids are the answer, and anything else in the response is not part of it.
|
||||
func orderItemsByID(items []json.RawMessage, ids []string) []json.RawMessage {
|
||||
byID := make(map[string]json.RawMessage, len(items))
|
||||
for _, raw := range items {
|
||||
if id := itemIDOf(raw); id != "" {
|
||||
if _, seen := byID[id]; !seen {
|
||||
byID[id] = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
ordered := make([]json.RawMessage, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if raw, ok := byID[id]; ok {
|
||||
ordered = append(ordered, raw)
|
||||
// Removed so a repeated id cannot draw the same card twice. The television
|
||||
// keys its rows by item id and throws on a duplicate.
|
||||
delete(byID, id)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
// viewerContinueRow is a shadow viewer's Continue Watching, built from their own playheads.
|
||||
func (s *Server) viewerContinueRow(
|
||||
ctx context.Context, cred emby.Credentials, viewerID string, limit int,
|
||||
) (*emby.ItemsResult, error) {
|
||||
ids, err := s.store.ViewerResumeItems(ctx, viewerID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.itemsByID(timing.WithLabel(ctx, "emby.viewer_resume"), cred, ids, fieldsContinue)
|
||||
}
|
||||
|
||||
// viewerNextUpRow is the next unwatched episode of each series this viewer is part-way
|
||||
// through. The ranking is Postgres's; Emby is only asked to describe the titles.
|
||||
func (s *Server) viewerNextUpRow(
|
||||
ctx context.Context, cred emby.Credentials, viewerID string, limit int,
|
||||
) (*emby.ItemsResult, error) {
|
||||
ids, err := s.store.ViewerNextUp(ctx, viewerID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.itemsByID(timing.WithLabel(ctx, "emby.viewer_nextup"), cred, ids, fieldsContinue)
|
||||
}
|
||||
|
||||
// viewerFavouritesRow is this viewer's own favourites rather than the account's.
|
||||
//
|
||||
// It is deliberately *not* re-sorted by name the way the Emby row is. A shadow viewer's
|
||||
// favourites are the ones they marked, and the order they marked them in is the only
|
||||
// ordering Memby has that means anything.
|
||||
func (s *Server) viewerFavouritesRow(
|
||||
ctx context.Context, cred emby.Credentials, viewerID string, limit int,
|
||||
) (*emby.ItemsResult, error) {
|
||||
ids, err := s.store.ViewerFavouriteItems(ctx, viewerID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.itemsByID(timing.WithLabel(ctx, "emby.viewer_favourites"), cred, ids, fieldsRow)
|
||||
}
|
||||
|
||||
// firstUnwatchedEpisodeFor is where a shadow viewer's series starts.
|
||||
//
|
||||
// It reads the series' episodes once from Emby and walks them against this viewer's own
|
||||
// played set, rather than asking Emby's NextUp — which answers for the account and is the
|
||||
// whole reason a shadow viewer pressing Play on a show they have never seen was being
|
||||
// dropped into the middle of somebody else's season.
|
||||
//
|
||||
// A part-watched episode wins over the first unwatched one: somebody eleven minutes into
|
||||
// an episode wants that episode, which is the same judgement the Continue Watching merge
|
||||
// makes.
|
||||
func (s *Server) firstUnwatchedEpisodeFor(
|
||||
ctx context.Context, cred emby.Credentials, viewer store.Viewer, seriesID string,
|
||||
) (*emby.Summary, error) {
|
||||
states, err := s.store.ViewerPlayedInSeries(ctx, viewer.ID, seriesID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(states) == 0 {
|
||||
// Never watched. Emby's own first episode is the right answer and costs the
|
||||
// caller nothing extra to ask for.
|
||||
return nil, nil
|
||||
}
|
||||
episodes, err := s.emby.Episodes(timing.WithLabel(ctx, "emby.viewer_series"), cred, seriesID, url.Values{
|
||||
"Fields": {"Overview,RunTimeTicks,SeriesName,ParentIndexNumber,IndexNumber"},
|
||||
"EnableUserData": {"false"},
|
||||
"EnableTotalRecordCount": {"false"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resume, err := s.store.ViewerStates(ctx, viewer.ID, episodeIDsOf(episodes.Items))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var firstUnplayed *emby.Summary
|
||||
for _, raw := range episodes.Items {
|
||||
summary, err := emby.Summarise(raw)
|
||||
if err != nil || summary.ID == "" {
|
||||
continue
|
||||
}
|
||||
state := resume[summary.ID]
|
||||
if state.PositionTicks > 0 && !state.Played {
|
||||
summary.UserData.PlaybackPositionTicks = state.PositionTicks
|
||||
return &summary, nil
|
||||
}
|
||||
if !state.Played && firstUnplayed == nil {
|
||||
episode := summary
|
||||
firstUnplayed = &episode
|
||||
}
|
||||
}
|
||||
return firstUnplayed, nil
|
||||
}
|
||||
|
||||
func episodeIDsOf(items []json.RawMessage) []string {
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, raw := range items {
|
||||
if id := itemIDOf(raw); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
Reference in New Issue
Block a user