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) } } }