Files
memby/server/internal/api/continue_watching.go
ponzischeme89andClaude Opus 5 4a4df7a73c App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the
player; MDBList ratings strip; episode and schedule detail pages; series
pace estimate; what's new panel; install-permission onboarding step;
synced per-profile preferences; Emby outage banner.

Gateway: rebuilt admin console (one fragment per page), preference
history and restore, merged Continue Watching, Emby health probe,
subtitle selection and Bazarr download, structured request logging with
per-request identity, and embedded build version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:33:56 +12:00

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
}