package library import ( "fmt" "strings" ) // Turning a Sonarr or Radarr notification into a piece of work, and all of it pure. // // The gateway used to learn that a file had appeared by asking Emby every hour whether // anything had been saved since the last time it asked. Sonarr and Radarr already know — // they are the things that put the file there — so this is the translation from what they // say into the one question the ingest worker answers: which item should be re-read, or // removed, and how do two deliveries of the same news collapse into one. // // Nothing here does I/O, which is what lets every rule below be a table test. // Ingest actions. A rename is deliberately a refresh like any other: the Emby item id // survives a file being moved, and so does the credits fingerprint measured against it — // the only stale thing is the row's payload. const ( ActionRefresh = "refresh" ActionRemove = "remove" ) // Ingest kinds. const ( KindEpisode = "episode" KindMovie = "movie" KindSeries = "series" ) // Why a request exists. It is carried through to the log and the console, because "this // episode was re-read because Sonarr upgraded the file" is the sentence an operator needs // and "an item changed" is not. const ( ReasonImport = "import" ReasonUpgrade = "upgrade" ReasonRename = "rename" ReasonDelete = "delete" ) // IngestRequest is one piece of work. It carries what the *arr knew rather than an Emby // id, because at the moment a webhook arrives Emby has very often not scanned the file in // yet and there is no id to carry. type IngestRequest struct { // Key is the dedupe identity, and it names the *file* rather than the event. Two // deliveries of one import collapse onto one row; a file deleted and re-imported is a // different file and therefore its own work. Same reasoning as the alert id in // radarrImportAlert. Key string `json:"-"` Action string `json:"-"` Kind string `json:"-"` Reason string `json:"-"` // Series identity, for an episode or a series-level event. Series string `json:"series,omitempty"` SeriesYear int `json:"seriesYear,omitempty"` Season int `json:"season,omitempty"` Episode int `json:"episode,omitempty"` // Film identity. Title string `json:"title,omitempty"` Year int `json:"year,omitempty"` // EmbyItemID is filled in only where the caller already knows it — a delete of // something the catalogue holds. Empty is the ordinary case. EmbyItemID string `json:"embyItemId,omitempty"` } // IngestResult is a finished piece of ingest work, handed to whoever wants to announce it. // // It is what makes "a scan has completed" a thing the gateway can say: a webhook only means // the *arr has moved a file, and the several minutes between that and Emby having scanned // it in are exactly the minutes in which a banner saying the title is there would be wrong. // This is emitted from the other end, once the row is in the catalogue. // // It carries both what the *arr said and what Emby turned out to call the thing, because // the news is about the title and the item id is what can put artwork behind it. type IngestResult struct { Source string // sonarr | radarr Kind string // KindEpisode | KindMovie | KindSeries Reason string // ReasonImport | ReasonUpgrade | ReasonRename | ReasonDelete // ItemID and Name are Emby's, filled in from the row that was just written. ItemID is // empty for a series-wide refresh, which is about a show rather than about one file. ItemID string Name string ImageTag string // SeriesName is Emby's name for the show an episode belongs to, which is what a banner // leads with — the episode's own Name is its title. SeriesName string Season int Episode int Year int } // SonarrWebhook is the subset of Sonarr's body this reads. Sonarr sends considerably // more; anything not named here is ignored on purpose, so a Sonarr upgrade that adds // fields cannot break the hook. type SonarrWebhook struct { EventType string `json:"eventType"` Series struct { ID int `json:"id"` Title string `json:"title"` Year int `json:"year"` } `json:"series"` Episodes []struct { ID int `json:"id"` SeasonNumber int `json:"seasonNumber"` EpisodeNumber int `json:"episodeNumber"` } `json:"episodes"` EpisodeFile struct { ID int `json:"id"` SeasonNumber int `json:"seasonNumber"` } `json:"episodeFile"` // RenamedEpisodeFiles is what On Rename carries: the files that moved, each with the // id the library already knows them by. RenamedEpisodeFiles []struct { ID int `json:"id"` SeasonNumber int `json:"seasonNumber"` } `json:"renamedEpisodeFiles"` IsUpgrade bool `json:"isUpgrade"` // DeletedFiles marks a series delete that took the media with it. A series removed // from Sonarr's list while its files stay on disk is not a reason to forget it. DeletedFiles bool `json:"deletedFiles"` } // RadarrWebhook is the same narrow reading of Radarr's body. type RadarrWebhook struct { EventType string `json:"eventType"` IsUpgrade bool `json:"isUpgrade"` Movie struct { ID int `json:"id"` Title string `json:"title"` Year int `json:"year"` } `json:"movie"` MovieFile struct { ID int `json:"id"` } `json:"movieFile"` MovieFileID int `json:"movieFileId"` DeletedFiles bool `json:"deletedFiles"` } // IsTestEvent reports the payload a webhook's Test button sends. It is answered 200 and // enqueues nothing, which is what makes that button mean "reachable" rather than // "reachable, and here is a row about a series that does not exist". func IsTestEvent(eventType string) bool { return strings.EqualFold(strings.TrimSpace(eventType), "Test") } // SonarrRequests turns one Sonarr notification into the work it implies. // // A notification can name several episodes — a multi-episode file, or a rename that moved // a season — so this answers a slice. Each carries its own key, because each is its own // file and the two may well arrive again separately. func SonarrRequests(payload SonarrWebhook) []IngestRequest { event := strings.ToLower(strings.TrimSpace(payload.EventType)) title := strings.TrimSpace(payload.Series.Title) switch event { case "download", "episodefileimported": if title == "" || len(payload.Episodes) == 0 { return nil } reason := ReasonImport if payload.IsUpgrade { // The file genuinely changed, so the row must be re-read. That it is not // *news* is a separate judgement, made by the alert half. reason = ReasonUpgrade } out := make([]IngestRequest, 0, len(payload.Episodes)) for _, episode := range payload.Episodes { out = append(out, IngestRequest{ Key: sonarrEpisodeKey(payload.EpisodeFile.ID, episode.ID), Action: ActionRefresh, Kind: KindEpisode, Reason: reason, Series: title, SeriesYear: payload.Series.Year, Season: episode.SeasonNumber, Episode: episode.EpisodeNumber, }) } return out case "rename": if title == "" { return nil } // A rename names files rather than episodes, and Sonarr does not say which episode // each file held. The series is the unit of work: one re-read of the show's // episodes settles every file that moved, and a season rename would otherwise be // one request per episode for the same answer. return []IngestRequest{{ Key: fmt.Sprintf("sonarr:series:%d:rename", payload.Series.ID), Action: ActionRefresh, Kind: KindSeries, Reason: ReasonRename, Series: title, SeriesYear: payload.Series.Year, }} case "episodefiledelete", "episodefiledeleted": if title == "" { return nil } season, episode := deletedEpisodeNumbers(payload) if episode <= 0 { return nil } return []IngestRequest{{ Key: fmt.Sprintf("sonarr:episodefile:%d:delete", payload.EpisodeFile.ID), Action: ActionRemove, Kind: KindEpisode, Reason: ReasonDelete, Series: title, SeriesYear: payload.Series.Year, Season: season, Episode: episode, }} case "seriesdelete", "seriesdeleted": // Only a delete that took the files. A series unfollowed in Sonarr while its // episodes stay on disk is still in the library and must stay in the catalogue. if title == "" || !payload.DeletedFiles { return nil } return []IngestRequest{{ Key: fmt.Sprintf("sonarr:series:%d:delete", payload.Series.ID), Action: ActionRemove, Kind: KindSeries, Reason: ReasonDelete, Series: title, SeriesYear: payload.Series.Year, }} } return nil } // sonarrEpisodeKey prefers the file id, which is the thing that actually changed. Sonarr // omits it on some versions of the import event, and the episode id is then the only // stable identity available — coarser, since it does not change when the file is // replaced, but a repeated delivery still collapses, which is what the key is for. func sonarrEpisodeKey(fileID, episodeID int) string { if fileID > 0 { return fmt.Sprintf("sonarr:episodefile:%d:%d", fileID, episodeID) } return fmt.Sprintf("sonarr:episode:%d", episodeID) } // deletedEpisodeNumbers reads the position of a deleted file. The episode list is // preferred because it carries the episode number; the file's own season number stands in // where the list is absent. func deletedEpisodeNumbers(payload SonarrWebhook) (int, int) { for _, episode := range payload.Episodes { if episode.EpisodeNumber > 0 { season := episode.SeasonNumber if season == 0 && payload.EpisodeFile.SeasonNumber > 0 { season = payload.EpisodeFile.SeasonNumber } return season, episode.EpisodeNumber } } return payload.EpisodeFile.SeasonNumber, 0 } // RadarrRequests turns one Radarr notification into the work it implies. func RadarrRequests(payload RadarrWebhook) []IngestRequest { event := strings.ToLower(strings.TrimSpace(payload.EventType)) title := strings.TrimSpace(payload.Movie.Title) if title == "" || payload.Movie.ID <= 0 { return nil } fileID := payload.MovieFile.ID if fileID <= 0 { fileID = payload.MovieFileID } switch event { case "download", "moviefileimported": reason := ReasonImport if payload.IsUpgrade { reason = ReasonUpgrade } return []IngestRequest{{ Key: radarrFileKey(payload.Movie.ID, fileID, reason), Action: ActionRefresh, Kind: KindMovie, Reason: reason, Title: title, Year: payload.Movie.Year, }} case "rename": return []IngestRequest{{ Key: fmt.Sprintf("radarr:movie:%d:rename", payload.Movie.ID), Action: ActionRefresh, Kind: KindMovie, Reason: ReasonRename, Title: title, Year: payload.Movie.Year, }} case "moviefiledelete", "moviefiledeleted": return []IngestRequest{{ Key: radarrFileKey(payload.Movie.ID, fileID, ReasonDelete), Action: ActionRemove, Kind: KindMovie, Reason: ReasonDelete, Title: title, Year: payload.Movie.Year, }} case "moviedelete", "moviedeleted": if !payload.DeletedFiles { return nil } return []IngestRequest{{ Key: fmt.Sprintf("radarr:movie:%d:delete", payload.Movie.ID), Action: ActionRemove, Kind: KindMovie, Reason: ReasonDelete, Title: title, Year: payload.Movie.Year, }} } return nil } func radarrFileKey(movieID, fileID int, reason string) string { if fileID > 0 { return fmt.Sprintf("radarr:moviefile:%d:%s", fileID, reason) } return fmt.Sprintf("radarr:movie:%d:%s", movieID, reason) } // NormalizedTitle strips a title down to the letters and digits it shares with whatever // the other system calls it, so "Marvel's Daredevil" and "Marvels Daredevil" are one show. // // It lives here because both halves of the gateway need it and there must be exactly one // answer to "which show is this": the schedule row matches Sonarr titles against the Emby // catalogue with it, and the ingest worker matches the same titles against Emby itself. func NormalizedTitle(value string) string { return strings.Map(func(r rune) rune { if r >= 'A' && r <= 'Z' { return r + ('a' - 'A') } if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { return r } return -1 }, value) }