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 } // 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...) } 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++ } // 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) } } } 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 }