206 lines
7.0 KiB
Go
206 lines
7.0 KiB
Go
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
|
||
|
|
}
|