Files

175 lines
6.2 KiB
Go
Raw Permalink Normal View History

2026-08-20 15:06:00 +12:00
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
}