356 lines
12 KiB
Go
356 lines
12 KiB
Go
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)
|
||
|
|
}
|