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