package api import ( "context" "encoding/json" "net/url" "strings" "time" "github.com/ponzischeme89/memby/server/internal/emby" ) // Continue Watching and Next Up answer the same question — "what am I in the middle // of?" — and splitting them across two rows meant a show moved between them the moment // an episode ended. Finishing something is exactly when a viewer is most likely to want // the next one, and that was the moment it left the first row on the launcher for one // further down it. They are one row now. // // Merging them is not concatenation, because both lists are ordered by recency and the // interesting case is a series that has just moved from one to the other. The order has // to be "most recently watched first" across both, which needs a time for each card: // // - A resume item carries its own UserData.LastPlayedDate. // - A Next Up episode carries nothing — it is unwatched, so its play date is zero. What // places it is when its *series* was last watched, which is what // recentlyPlayedSeries goes and asks for. const ( // How far back to look for the play that puts a Next Up episode in order. This is a // household's recent viewing, not its history: a series nobody has touched in this // many plays is not competing for the front of the row anyway. continuePlayLookback = 120 ) // recentlyPlayedSeries maps series id to the last time anything in it was played. // // It is one narrow, image-free list request beside the four the launcher already makes. // Emby returns it most-recently-played first, so the first entry seen for a series is // the one that counts. func (s *Server) recentlyPlayedSeries( ctx context.Context, cred emby.Credentials, ) (map[string]time.Time, error) { result, err := s.emby.Items(ctx, cred, url.Values{ "IncludeItemTypes": {"Episode"}, "Recursive": {"true"}, "Filters": {"IsPlayed"}, "SortBy": {"DatePlayed"}, "SortOrder": {"Descending"}, "Limit": {itoa(continuePlayLookback)}, "Fields": {"SeriesId"}, "EnableImages": {"false"}, "EnableUserData": {"true"}, "EnableTotalRecordCount": {"false"}, }) if err != nil { return nil, err } played := make(map[string]time.Time, len(result.Items)) for _, raw := range result.Items { seriesID, at, ok := seriesPlayedAt(raw) if !ok { continue } if _, seen := played[seriesID]; !seen { played[seriesID] = at } } return played, nil } type continueItem struct { raw json.RawMessage at time.Time dated bool } // mergeContinueWatching interleaves the resume list and the Next Up list into one row, // most recently watched first. // // Two properties are load-bearing and pinned by tests. Each source keeps its own // relative order — Emby's ordering within a list is the useful part and this must never // re-rank it — so this is a merge of two sorted lists, never a sort of their union. And // a card with no usable date never displaces one that has: an unknown time falls back to // the resume list first, which is the half that is definitely in progress. // // A series present in both is represented once, by its resume item: somebody who is // eleven minutes into an episode wants that episode, not the one after it. func mergeContinueWatching( resume []json.RawMessage, nextUp []json.RawMessage, seriesPlayed map[string]time.Time, ) []json.RawMessage { seenItems := make(map[string]bool, len(resume)) seenSeries := make(map[string]bool, len(resume)) inProgress := make([]continueItem, 0, len(resume)) for _, raw := range resume { id, seriesID, at, dated := continueItemFields(raw, seriesPlayed) if id != "" { seenItems[id] = true } if seriesID != "" { seenSeries[seriesID] = true } inProgress = append(inProgress, continueItem{raw: raw, at: at, dated: dated}) } upNext := make([]continueItem, 0, len(nextUp)) for _, raw := range nextUp { id, seriesID, at, dated := continueItemFields(raw, seriesPlayed) if (id != "" && seenItems[id]) || (seriesID != "" && seenSeries[seriesID]) { continue } if id != "" { seenItems[id] = true } if seriesID != "" { seenSeries[seriesID] = true } upNext = append(upNext, continueItem{raw: raw, at: at, dated: dated}) } merged := make([]json.RawMessage, 0, len(inProgress)+len(upNext)) left, right := 0, 0 for left < len(inProgress) && right < len(upNext) { next := upNext[right] current := inProgress[left] if next.dated && (!current.dated || next.at.After(current.at)) { merged = append(merged, next.raw) right++ continue } merged = append(merged, current.raw) left++ } for ; left < len(inProgress); left++ { merged = append(merged, inProgress[left].raw) } for ; right < len(upNext); right++ { merged = append(merged, upNext[right].raw) } return merged } // continueItemFields reads the identity and the time that places one card. An episode is // timed by its own play date where it has one and by its series' otherwise, which is what // puts a freshly unlocked Next Up episode ahead of a resume from last week. func continueItemFields( raw json.RawMessage, seriesPlayed map[string]time.Time, ) (id string, seriesID string, at time.Time, dated bool) { var item struct { ID string `json:"Id"` SeriesID string `json:"SeriesId"` UserData struct { LastPlayedDate string `json:"LastPlayedDate"` } `json:"UserData"` } if json.Unmarshal(raw, &item) != nil { return "", "", time.Time{}, false } if parsed, ok := parsePlayedAt(item.UserData.LastPlayedDate); ok { return item.ID, item.SeriesID, parsed, true } if item.SeriesID != "" { if parsed, ok := seriesPlayed[item.SeriesID]; ok { return item.ID, item.SeriesID, parsed, true } } return item.ID, item.SeriesID, time.Time{}, false } func seriesPlayedAt(raw json.RawMessage) (string, time.Time, bool) { var item struct { SeriesID string `json:"SeriesId"` UserData struct { LastPlayedDate string `json:"LastPlayedDate"` } `json:"UserData"` } if json.Unmarshal(raw, &item) != nil || item.SeriesID == "" { return "", time.Time{}, false } at, ok := parsePlayedAt(item.UserData.LastPlayedDate) if !ok { return "", time.Time{}, false } return item.SeriesID, at, true } // parsePlayedAt reads Emby's play timestamps. It writes .NET-style seven-digit fractional // seconds, which RFC 3339 parsing handles, but older records and some plugins write a // bare "yyyy-MM-ddTHH:mm:ssZ" — an unparseable date must leave the card undated rather // than dropping it out of the row. func parsePlayedAt(value string) (time.Time, bool) { value = strings.TrimSpace(value) // Emby's "never played" sentinel. A real date it is not, and treating it as one would // order a card by a year nobody watched anything in. if value == "" || strings.HasPrefix(value, "0001-01-01") { return time.Time{}, false } for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05"} { if parsed, err := time.Parse(layout, value); err == nil { return parsed, true } } return time.Time{}, false }