0.2.76 - Icon Packs
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user