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. } func (s *Server) announceImportedMovie(ctx context.Context, result library.IngestResult) { window := s.radarrAlertWindow() title := strings.TrimSpace(result.Name) if window <= 0 || title == "" || result.ItemID == "" { return } now := time.Now().UTC() name := title if result.Year > 0 { name = fmt.Sprintf("%s (%d)", title, result.Year) } s.publishAlert(ctx, 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 window <= 0 || series == "" || result.ItemID == "" { return } now := time.Now().UTC() run := s.ingestRuns.record( seasonRunKey(series, result.Season), result.ItemID, episodeSummary(result), now, ingestRunWindow, ) s.publishAlert(ctx, 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} }