Files
memby/server/internal/api/requests_ready.go
T

218 lines
8.8 KiB
Go
Raw Normal View History

2026-08-19 14:25:44 +12:00
package api
import (
"context"
"fmt"
"strconv"
"time"
"github.com/ponzischeme89/memby/server/internal/notify"
"github.com/ponzischeme89/memby/server/internal/scheduler"
"github.com/ponzischeme89/memby/server/internal/store"
)
// Telling somebody the thing they asked for has arrived.
//
// This is the one part of the request feature that cannot be answered by looking: every
// other state on a card is derived per read from the *arrs and the library, but "it has just
// become ready" is a *difference between two observations*, and a viewer has to be told about
// it exactly once. media_requests.last_status is the memory that makes the difference
// visible; this sweep is what looks.
//
// It is a personal notification rather than a service alert, and that is the same distinction
// the watch-time digest makes: a service alert is the house being told something, and one
// person's request arriving is news for one person. It therefore lands in My Alerts and
// follows them to whichever television they sign into, rather than dropping a bar across
// somebody else's film.
const (
// requestReadyKind is what the television files the notification under. An app that
// predates it draws the fallback icon, which is why the wording has to stand on its own.
requestReadyKind = "request-ready"
// notifySourceRequestReady names this producer in the notification log, so an operator
// answering "did Memby tell them?" has one thing to filter on.
notifySourceRequestReady = "request-ready"
)
// RegisterRequestTasks declares the arrival sweep.
//
// Registered as an ordinary scheduler job for the reason the digest is: an operator can see
// when it last ran and can run one by hand. For a job whose whole output is silence on a
// quiet day, that is the difference between "nothing has arrived" and "it has not run".
func (s *Server) RegisterRequestTasks(sched *scheduler.Scheduler) {
if sched == nil {
return
}
sched.Register(scheduler.Task{
ID: "request-ready-scan",
Name: "Requested content arrivals",
Group: "Notifications",
Description: "Notices when something a viewer requested has become watchable and tells " +
"the person who asked for it. Needs Radarr or Sonarr.",
// Five minutes is chosen against what it is watching rather than against cost. An
// import is the last few seconds of a wait measured in tens of minutes, so being up
// to five minutes late with the news is invisible; being an hour late is the
// difference between a notification and a thing somebody had already found for
// themselves.
Interval: 5 * time.Minute,
Timeout: 2 * time.Minute,
Run: s.runRequestReadyScan,
})
}
func (s *Server) runRequestReadyScan(ctx context.Context) (string, error) {
if s.store == nil {
return "", nil
}
// Nothing to compare against with both catalogues gone: every card would read
// "requested", which is not a transition and must not be recorded as one.
if !s.radarrEnabled(ctx) && !s.sonarrEnabled(ctx) {
return "", nil
}
2026-08-28 23:00:02 +12:00
// Retire pins nobody has acted on in a fortnight before looking at anything else, so a
// stale REQUEST READY tag is gone from Home within a sweep of its deadline rather than
// leaning on SurfacedReadyRequests' own window clause to keep hiding it.
if expired, err := s.store.ExpireStaleReadyRequests(ctx); err != nil {
s.loggerFor(ctx).Warn("stale request pins not expired", "error", err)
} else {
s.invalidateHomeFor(ctx, expired...)
}
2026-08-19 14:25:44 +12:00
owned, err := s.store.AllMediaRequests(ctx, store.MediaRequestSweepLimit)
if err != nil {
return "", fmt.Errorf("request arrivals: read requests: %w", err)
}
if len(owned) == 0 {
return "", nil
}
stored := make([]store.MediaRequest, 0, len(owned))
for _, req := range owned {
stored = append(stored, req.MediaRequest)
}
// progressAware is true because nothing here is drawn: the sweep wants the truth, and
// collapsing it would make a download and a search indistinguishable in the memory this
// writes back.
cards := s.decorateRequests(ctx, stored, true)
if len(cards) != len(owned) {
// decorateRequests answers one card per stored ask, in order. If that ever stopped
// being true the zip below would attribute one person's arrival to another, which is
// the one mistake here that reaches somebody as a notification about a title they
// never asked for.
return "", fmt.Errorf("request arrivals: %d cards for %d requests", len(cards), len(owned))
}
announced, moved := 0, 0
for index, card := range cards {
req := owned[index]
if card.Status == req.LastStatus {
continue
}
// A state that could not be established is not a transition. With Radarr restarting,
// every card falls back to "requested", and recording that would throw away the
// memory of what each request was actually doing — so the next sweep, once Radarr is
// back, would read an arrival as a move from "requested" and announce titles that had
// been on the shelf for a week.
if card.Status == RequestStatusRequested {
continue
}
if card.Status == RequestStatusAvailable && req.LastStatus != RequestStatusAvailable {
if s.announceRequestReady(ctx, req, card) {
announced++
}
2026-08-28 23:00:02 +12:00
// Pin it to the front of the requester's Continue Watching row until they play
// it. Only possible once the library has an item id — a title the *arr reports a
// file for but Emby has not imported yet is still worth the notification above,
// but there is nothing for a card to open. MarkRequestReadySurfaced's own guard
// makes the repeat a no-op, so this is safe to call on every observed transition.
if card.EmbyItemID != "" {
if err := s.store.MarkRequestReadySurfaced(
ctx, req.UserID, req.MediaType, req.ForeignID, card.EmbyItemID,
); err != nil {
s.loggerFor(ctx).Warn("request ready pin not recorded",
"user_id", req.UserID, "type", req.MediaType,
"foreign_id", req.ForeignID, "error", err)
} else {
s.invalidateHomeFor(ctx, req.UserID)
}
}
2026-08-19 14:25:44 +12:00
}
if err := s.store.SetMediaRequestStatus(
ctx, req.UserID, req.MediaType, req.ForeignID, card.Status,
); err != nil {
s.loggerFor(ctx).Warn("request status not recorded",
"user_id", req.UserID, "type", req.MediaType,
"foreign_id", req.ForeignID, "error", err)
continue
}
moved++
}
// An empty detail keeps a job that runs every five minutes out of the operator's feed
// every five minutes; see scheduler.announce. Movement without an arrival is real work
// and worth reporting, because it is the evidence that the sweep is watching anything
// at all on a household where nothing has finished downloading yet.
switch {
case announced == 1:
return "1 request ready", nil
case announced > 1:
return fmt.Sprintf("%d requests ready", announced), nil
case moved > 0:
return fmt.Sprintf("%d requests moved on", moved), nil
default:
return "", nil
}
}
// announceRequestReady tells one viewer their title has landed.
//
// It carries the Emby item id whenever the library has one, which is what lets pressing the
// notification open the title's own detail page rather than a page about nothing. A title the
// *arr reports a file for but Emby has not imported yet legitimately has none — the news is
// still true and still worth sending, and the notification simply is not a link.
//
// There is deliberately no preference check. The other personal notifications are things
// Memby decided to send somebody; this one is the answer to a question they asked by pressing
// a button, and a viewer who has requested a film has said as clearly as they can that they
// want to know when it arrives.
func (s *Server) announceRequestReady(
ctx context.Context, req store.OwnedMediaRequest, card myRequest,
) bool {
title := card.Title
if title == "" {
title = req.Title
}
thing := "film"
if req.MediaType == "series" {
thing = "series"
}
eventAt := time.Now()
sent := s.notifyUser(ctx, notify.Notification{
Kind: requestReadyKind,
Source: notifySourceRequestReady,
UserID: req.UserID,
Title: "Your " + thing + " is ready",
Body: title + " is now available to watch.",
ItemID: card.EmbyItemID,
// The key names the request rather than the moment, so the arrival is announced once
// however many times the sweep runs — and a viewer who deletes the request, asks
// again and waits through a second download is a second key only if the title left
// the library in between, which is the case where the news is genuinely new.
SourceKey: "request-ready:" + req.MediaType + ":" + strconv.Itoa(req.ForeignID) +
":" + req.UserID,
EventAt: &eventAt,
Metadata: map[string]any{
"mediaType": req.MediaType,
"foreignId": req.ForeignID,
},
})
if sent {
s.loggerFor(ctx).Info("requested title is ready",
"user_id", req.UserID, "type", req.MediaType,
"title", clientLogValue(title), "foreign_id", req.ForeignID,
"item_id", card.EmbyItemID)
}
return sent
}