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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user