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 }