0.3.49
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// Pinning a freshly arrived request to the front of Continue Watching.
|
||||
//
|
||||
// The notification (requests_ready.go) tells the person who asked that their title has
|
||||
// landed; this is the other half — putting it where they actually look for something to
|
||||
// watch. An unwatched film never appears in Continue Watching on its own and a new series
|
||||
// only shows up if they go hunting, so without this the request journey ends a step short of
|
||||
// its payoff.
|
||||
//
|
||||
// It is deliberately cheap. The only new work on the Home path is one indexed Postgres read
|
||||
// (SurfacedReadyRequests), and — only when that read is non-empty, which is rare — one
|
||||
// batched Emby item lookup for the titles not already in the row. No Radarr or Sonarr call
|
||||
// is ever made here.
|
||||
|
||||
const (
|
||||
// requestReadyField and requestReadyLabelField are what the television reads. The label
|
||||
// is the server's wording (the MembyAirLabel precedent), so renaming it needs no app
|
||||
// release; a client that predates the field falls back to its own constant.
|
||||
requestReadyField = "MembyRequestReady"
|
||||
requestReadyLabelField = "MembyRequestReadyLabel"
|
||||
// requestReadyLabel is the tag drawn on the card.
|
||||
requestReadyLabel = "REQUEST READY"
|
||||
)
|
||||
|
||||
// injectRequestReady tags or prepends the viewer's still-pinned request arrivals in the
|
||||
// merged Continue Watching list.
|
||||
//
|
||||
// - A surfaced title already in the row (the film itself, or the series an episode belongs
|
||||
// to) is tagged in place — no second card.
|
||||
// - One not in the row is fetched from Emby and prepended, newest arrival first.
|
||||
//
|
||||
// Any failure degrades to the list untouched: the person was already told by notification,
|
||||
// and a missing pin is a smaller disappointment than a broken launcher.
|
||||
func (s *Server) injectRequestReady(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
items []json.RawMessage,
|
||||
surfaced []store.SurfacedReadyRequest,
|
||||
) []json.RawMessage {
|
||||
if len(surfaced) == 0 {
|
||||
return items
|
||||
}
|
||||
missing := missingRequestReady(items, surfaced)
|
||||
if len(missing) == 0 {
|
||||
return mergeRequestReady(items, surfaced, nil)
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(missing))
|
||||
for _, req := range missing {
|
||||
ids = append(ids, req.ItemID)
|
||||
}
|
||||
fetched, err := s.emby.Items(ctx, cred, rowParams(url.Values{
|
||||
"Ids": {strings.Join(ids, ",")},
|
||||
"Recursive": {"true"},
|
||||
}, fieldsContinue))
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("request ready items unavailable", "error", err)
|
||||
// The titles already in the row are still worth tagging even when the fetch for the
|
||||
// rest fails.
|
||||
return mergeRequestReady(items, surfaced, nil)
|
||||
}
|
||||
|
||||
byID := make(map[string]json.RawMessage, len(fetched.Items))
|
||||
for _, raw := range fetched.Items {
|
||||
if id, _, _, _ := continueItemFields(raw, nil); id != "" {
|
||||
byID[id] = raw
|
||||
}
|
||||
}
|
||||
return mergeRequestReady(items, surfaced, byID)
|
||||
}
|
||||
|
||||
// requestReadyIndex maps every item and series id already in the row to its position.
|
||||
func requestReadyIndex(items []json.RawMessage) map[string]int {
|
||||
present := make(map[string]int, len(items))
|
||||
for index, raw := range items {
|
||||
id, seriesID, _, _ := continueItemFields(raw, nil)
|
||||
if id != "" {
|
||||
present[id] = index
|
||||
}
|
||||
if seriesID != "" {
|
||||
if _, ok := present[seriesID]; !ok {
|
||||
present[seriesID] = index
|
||||
}
|
||||
}
|
||||
}
|
||||
return present
|
||||
}
|
||||
|
||||
// missingRequestReady is the surfaced arrivals whose title is not already in the row.
|
||||
func missingRequestReady(
|
||||
items []json.RawMessage, surfaced []store.SurfacedReadyRequest,
|
||||
) []store.SurfacedReadyRequest {
|
||||
present := requestReadyIndex(items)
|
||||
missing := make([]store.SurfacedReadyRequest, 0, len(surfaced))
|
||||
for _, req := range surfaced {
|
||||
if _, ok := present[req.ItemID]; !ok {
|
||||
missing = append(missing, req)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// mergeRequestReady is the pure half: stamp the arrivals already in the row in place, and
|
||||
// prepend the rest from `fetched` (newest first), stamped. An arrival with no fetched item
|
||||
// is simply left out — the notification already carried the news.
|
||||
func mergeRequestReady(
|
||||
items []json.RawMessage,
|
||||
surfaced []store.SurfacedReadyRequest,
|
||||
fetched map[string]json.RawMessage,
|
||||
) []json.RawMessage {
|
||||
out := make([]json.RawMessage, len(items))
|
||||
copy(out, items)
|
||||
present := requestReadyIndex(out)
|
||||
|
||||
var missing []store.SurfacedReadyRequest
|
||||
for _, req := range surfaced {
|
||||
if index, ok := present[req.ItemID]; ok {
|
||||
out[index] = stampRequestReady(out[index])
|
||||
continue
|
||||
}
|
||||
missing = append(missing, req)
|
||||
}
|
||||
// surfaced is ordered newest first, so this block keeps that order and the whole block
|
||||
// goes in front of the existing row.
|
||||
prepend := make([]json.RawMessage, 0, len(missing))
|
||||
for _, req := range missing {
|
||||
if raw, ok := fetched[req.ItemID]; ok {
|
||||
prepend = append(prepend, stampRequestReady(raw))
|
||||
}
|
||||
}
|
||||
if len(prepend) == 0 {
|
||||
return out
|
||||
}
|
||||
return append(prepend, out...)
|
||||
}
|
||||
|
||||
// stampRequestReady sets the two tag fields on one item's JSON, leaving everything else
|
||||
// alone. It mirrors decorateItemRatings' approach — decode to a map, set, re-encode — so a
|
||||
// field the row does not model is preserved.
|
||||
func stampRequestReady(raw json.RawMessage) json.RawMessage {
|
||||
var item map[string]any
|
||||
if err := json.Unmarshal(raw, &item); err != nil || item == nil {
|
||||
return raw
|
||||
}
|
||||
item[requestReadyField] = true
|
||||
item[requestReadyLabelField] = requestReadyLabel
|
||||
if stamped, err := json.Marshal(item); err == nil {
|
||||
return stamped
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// invalidateHomeFor drops the cached Home payload for each named viewer, best-effort. Used
|
||||
// wherever a request pin is created or retired outside a Home request itself — the ready
|
||||
// sweep and the playback handler.
|
||||
func (s *Server) invalidateHomeFor(ctx context.Context, userIDs ...string) {
|
||||
if s.cache == nil {
|
||||
return
|
||||
}
|
||||
for _, userID := range userIDs {
|
||||
if userID == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.cache.InvalidateUser(ctx, userID); err != nil {
|
||||
s.loggerFor(ctx).Warn("home cache not invalidated", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user