215 lines
8.1 KiB
Go
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}
|
|
}
|