Files
memby/server/internal/library/events.go
T
2026-08-18 14:59:29 +12:00

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)
}