180 lines
6.0 KiB
Go
180 lines
6.0 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|