Files
memby/server/internal/api/ingest_alerts.go
T
2026-08-19 06:57:59 +12:00

215 lines
8.1 KiB
Go

package api
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/library"
)
// News about a finished Sonarr or Radarr scan.
//
// The webhook is not the news. Both *arrs fire the moment they have moved a file, and Emby
// has not scanned it in yet — which is why the banner this replaces had to say a film would
// be available "shortly", and why an episode could not be announced at all: there was
// nothing truthful to say about one until it was actually there. The gateway now knows when
// that moment arrives, because the ingest worker is what makes it arrive, so the
// announcement is made from the far end of the scan and says the title is ready.
//
// The cost is that the news is a minute or two later than the webhook, and that a title Emby
// never manages to scan is never announced. Both are the right way round: a notice about
// something a viewer can press Play on is worth more than an earlier one about something
// they cannot.
const (
// ingestRunWindow is how long two imports count as one piece of news. A season pack
// arrives as a dozen webhooks over a couple of minutes, and a household does not want a
// dozen banners about it — it wants to be told the show has new episodes.
ingestRunWindow = 15 * time.Minute
// trackedIngestRuns bounds the tally. A household imports a handful of things at once;
// this is generous enough that a season pack always collapses and small enough that it
// can never grow into a leak.
trackedIngestRuns = 64
)
// AnnounceLibraryIngest turns a completed scan into the banner every open television shows.
//
// Only a genuine import is announced. An upgrade is deliberately silent — the title was
// already there, and "new episode" would be a lie about a file that was replaced with a
// better copy — and so are a rename and a delete, which are housekeeping rather than news.
// That judgement lives here rather than in the worker: the worker's business is that the
// row changed, this is the separate question of whether anybody should be told.
func (s *Server) AnnounceLibraryIngest(ctx context.Context, result library.IngestResult) {
if result.Reason != library.ReasonImport {
return
}
switch result.Kind {
case library.KindMovie:
s.announceImportedMovie(ctx, result)
case library.KindEpisode:
s.announceImportedEpisode(ctx, result)
}
// A series-level result is a rename settling or a show written ahead of its first
// episode. Neither is a title somebody can watch, and the episode that follows is.
}
// The zero-window case is deliberately not short-circuited here any more. An operator who
// has switched movie import banners off is a reason nobody was told, and the notification
// log is where that answer belongs — deliverBroadcast records it as a skip rather than the
// producer returning in silence.
func (s *Server) announceImportedMovie(ctx context.Context, result library.IngestResult) {
window := s.radarrAlertWindow()
title := strings.TrimSpace(result.Name)
if title == "" || result.ItemID == "" {
return
}
now := time.Now().UTC()
name := title
if result.Year > 0 {
name = fmt.Sprintf("%s (%d)", title, result.Year)
}
s.broadcast(ctx, notifySourceLibraryIngest, clientAlert{
// Keyed on the Emby item, so a repeated delivery of one import is one banner while
// a film deleted and re-imported is news again. Clients dedupe on this id forever.
ID: "ingest:movie:" + result.ItemID,
Kind: alertKindRadarrImport,
Label: "NEW MOVIE ADDED",
Title: name,
Message: fmt.Sprintf("%s is ready to watch.", title),
ItemID: result.ItemID,
ImageTag: result.ImageTag,
AiredAt: now.Format(time.RFC3339),
}, window)
s.loggerFor(ctx).Info("library ingest announced",
"event", "arr_ingest_alert", "source", result.Source, "kind", result.Kind,
"title", name, "item", result.ItemID)
}
func (s *Server) announceImportedEpisode(ctx context.Context, result library.IngestResult) {
// The Sonarr window, so MEMBY_SONARR_ALERT_WINDOW=0 switches episode news off exactly
// as it does the "aired, coming soon" one, without touching films.
window := s.sonarrAlertWindow()
series := strings.TrimSpace(result.SeriesName)
if series == "" || result.ItemID == "" {
return
}
now := time.Now().UTC()
run := s.ingestRuns.record(
seasonRunKey(series, result.Season), result.ItemID,
episodeSummary(result), now, ingestRunWindow,
)
s.broadcast(ctx, notifySourceLibraryIngest, clientAlert{
// The run's *first* episode anchors the id, so every later arrival in the same
// season pack replaces one banner rather than stacking another — and next week's
// episode, arriving after the window has closed, starts a run of its own and is
// therefore its own news rather than one the fleet has already dismissed as seen.
ID: "ingest:episode:" + run.Anchor,
Kind: alertKindSonarrImport,
Label: "NEW EPISODE ADDED",
Title: series,
Message: episodeRunMessage(run),
ItemID: result.ItemID,
ImageTag: result.ImageTag,
AiredAt: now.Format(time.RFC3339),
}, window)
s.loggerFor(ctx).Info("library ingest announced",
"event", "arr_ingest_alert", "source", result.Source, "kind", result.Kind,
"series", series, "episodes", run.Count, "item", result.ItemID)
}
// episodeSummary is how one episode is named in a banner: "S03E05 — The Bear". The code
// alone is what a viewer scanning a shelf recognises, and the title is what tells them it
// is the one they were waiting for, so both are kept where both exist.
func episodeSummary(result library.IngestResult) string {
code := ""
if result.Season > 0 || result.Episode > 0 {
code = fmt.Sprintf("S%02dE%02d", result.Season, result.Episode)
}
title := strings.TrimSpace(result.Name)
switch {
case code == "":
return title
case title == "" || strings.EqualFold(title, result.SeriesName):
return code
default:
return fmt.Sprintf("%s — %s", code, title)
}
}
// episodeRunMessage words one arrival by name and several by count. Naming the last of six
// would be arbitrary — nothing makes it the one worth mentioning — where the count is the
// thing the viewer actually wants to know.
func episodeRunMessage(run ingestRun) string {
if run.Count > 1 {
return fmt.Sprintf("%d new episodes are ready to watch.", run.Count)
}
if run.Latest == "" {
return "A new episode is ready to watch."
}
return fmt.Sprintf("%s is ready to watch.", run.Latest)
}
func seasonRunKey(series string, season int) string {
return fmt.Sprintf("%s|%d", library.NormalizedTitle(series), season)
}
// ingestRun is what a season's imports have amounted to so far.
type ingestRun struct {
// Anchor is the first item id seen in this run, and is what keeps a burst of banners
// collapsed onto one.
Anchor string
Count int
Latest string
}
// ingestRuns collapses a burst of imports of one season into a single piece of news.
//
// Deliberately in memory and deliberately lossy, the playbackTitles arrangement: a gateway
// restarted half way through a season pack announces the rest as a second run, which is a
// far better trade than a table recording what a banner said.
type ingestRuns struct {
mu sync.Mutex
runs map[string]*runState
order []string
}
type runState struct {
anchor string
count int
latest string
until time.Time
}
// record folds one import into its season's run and reports where that run now stands. A
// run whose window has closed is replaced rather than extended, so a show importing an
// episode a week is a separate notice every week.
func (r *ingestRuns) record(
key, itemID, summary string, now time.Time, window time.Duration,
) ingestRun {
r.mu.Lock()
defer r.mu.Unlock()
if r.runs == nil {
r.runs = make(map[string]*runState, trackedIngestRuns)
}
state, live := r.runs[key]
if !live || !state.until.After(now) {
if !live {
r.order = append(r.order, key)
if len(r.order) > trackedIngestRuns {
delete(r.runs, r.order[0])
r.order = r.order[1:]
}
}
state = &runState{anchor: itemID}
r.runs[key] = state
}
state.count++
state.latest = summary
state.until = now.Add(window)
return ingestRun{Anchor: state.anchor, Count: state.count, Latest: state.latest}
}