175 lines
6.2 KiB
Go
175 lines
6.2 KiB
Go
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
|
|
}
|