0.2.78
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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
|
||||
}
|
||||
|
||||
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++
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user