Files

180 lines
6.0 KiB
Go
Raw Permalink Normal View History

2026-08-28 23:00:02 +12:00
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)
}
}
}