741 lines
25 KiB
Go
741 lines
25 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/emby"
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
// The worker that drains what Sonarr and Radarr told us.
|
|
//
|
|
// The scheduled import asks Emby "what has changed since an hour ago" and pages through
|
|
// the answer. This asks Emby "where is this one episode", which is a request whose size
|
|
// does not grow with the library, and it asks only because something that actually puts
|
|
// files on disk said there was a reason to.
|
|
//
|
|
// Emby is the *lookup* here and never the discovery mechanism. Nothing in this file
|
|
// enumerates a library, and the one thing that still does — Syncer.Schedule — is demoted
|
|
// to reconciliation for media the *arrs do not manage.
|
|
|
|
const (
|
|
// defaultSettleDelay is how long after a webhook the first attempt is made. Sonarr
|
|
// fires On Import the moment it has moved the file; Emby has not scanned it yet, and
|
|
// asking immediately would spend a request to learn that.
|
|
defaultSettleDelay = 60 * time.Second
|
|
|
|
// idlePoll is how often the worker looks for due work. Coarse on purpose: everything
|
|
// here is already late by a settle delay, and a tight loop against Postgres on an idle
|
|
// NAS is exactly the background cost this replaces.
|
|
idlePoll = 20 * time.Second
|
|
|
|
// claimBatch bounds one pass. A season pack arrives as a dozen notifications at once
|
|
// and there is no hurry: draining a few per pass keeps Emby's request rate flat.
|
|
claimBatch = 4
|
|
|
|
// maxAttempts is where a piece of work is given up on. With the backoff below that is
|
|
// most of a day, after which the item is the reconciliation sweep's problem — which is
|
|
// the honest answer, since something other than timing is wrong by then.
|
|
maxAttempts = 7
|
|
|
|
// jobBudget bounds one piece of work end to end.
|
|
jobBudget = 60 * time.Second
|
|
)
|
|
|
|
// IngestStore is the slice of the store this needs. Narrow so the whole worker can be
|
|
// exercised against maps in a test, and so it is visible at a glance that the only things
|
|
// it writes are catalogue rows and the queue's own state.
|
|
type IngestStore interface {
|
|
EnqueueIngest(ctx context.Context, job store.IngestJob) (bool, error)
|
|
ClaimIngest(ctx context.Context, now time.Time, limit int) ([]store.IngestJob, error)
|
|
FinishIngest(ctx context.Context, key, state, outcome, itemID, errorText string, retryAt time.Time) error
|
|
UpsertLibraryItems(ctx context.Context, items []store.LibraryItem, syncedAt time.Time) (int64, error)
|
|
DeleteLibraryItem(ctx context.Context, itemID string) (int64, error)
|
|
SeriesRefs(ctx context.Context) ([]store.SeriesRef, error)
|
|
CreditsSeriesEpisodes(ctx context.Context, seriesIDs []string) ([]store.CreditsEpisodeRow, error)
|
|
LibraryItemsByName(ctx context.Context, itemType, name string) ([]store.NamedItem, error)
|
|
}
|
|
|
|
// EmbySource is the slice of Emby this needs: two reads and one nudge.
|
|
type EmbySource interface {
|
|
Items(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error)
|
|
Episodes(ctx context.Context, cred emby.Credentials, seriesID string, params url.Values) (*emby.ItemsResult, error)
|
|
RefreshItem(ctx context.Context, cred emby.Credentials, itemID string) error
|
|
}
|
|
|
|
// Ingester drains the durable queue.
|
|
type Ingester struct {
|
|
Store IngestStore
|
|
Emby EmbySource
|
|
Credentials func(ctx context.Context) (emby.Credentials, error)
|
|
Log *slog.Logger
|
|
// Paused is the server-wide quiet-time gate. The queue is durable precisely so this can
|
|
// say no: a webhook that arrives during quiet hours is recorded and read afterwards,
|
|
// where the old arrangement answered it 503 and lost the event outright.
|
|
Paused func() bool
|
|
// Settle is the delay applied when work is enqueued. Held here so the hook and the
|
|
// worker cannot disagree about it.
|
|
Settle time.Duration
|
|
// Announce is told about a finished import, so the news reaches the televisions from
|
|
// the moment the title is actually there rather than from the moment the *arr said it
|
|
// would be. Installed from main.go, like syncer.SetAfterSync and for the same reason:
|
|
// library has no business knowing what an alert is. Nil is ordinary — a gateway with
|
|
// nothing to announce to, and every test in this package.
|
|
Announce func(ctx context.Context, result IngestResult)
|
|
}
|
|
|
|
func (i *Ingester) log() *slog.Logger {
|
|
if i == nil || i.Log == nil {
|
|
return slog.Default()
|
|
}
|
|
return i.Log
|
|
}
|
|
|
|
// SettleDelay is what the hook stamps onto a new row.
|
|
func (i *Ingester) SettleDelay() time.Duration {
|
|
if i == nil || i.Settle <= 0 {
|
|
return defaultSettleDelay
|
|
}
|
|
return i.Settle
|
|
}
|
|
|
|
// Run is the worker. One goroutine for the whole gateway.
|
|
func (i *Ingester) Run(ctx context.Context) {
|
|
if i == nil || i.Store == nil || i.Emby == nil || i.Credentials == nil {
|
|
return
|
|
}
|
|
i.log().Info("library ingest worker started", "settle", i.SettleDelay().String())
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
worked := false
|
|
if i.Paused == nil || !i.Paused() {
|
|
worked = i.drain(ctx)
|
|
}
|
|
if worked {
|
|
continue
|
|
}
|
|
if !sleep(ctx, idlePoll) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// drain works everything currently due and reports whether it did anything, so a busy
|
|
// queue is emptied without waiting a poll interval between rows.
|
|
func (i *Ingester) drain(ctx context.Context) bool {
|
|
jobs, err := i.Store.ClaimIngest(ctx, time.Now().UTC(), claimBatch)
|
|
if err != nil {
|
|
if ctx.Err() == nil {
|
|
i.log().Warn("could not read the ingest queue", "error", err)
|
|
}
|
|
return false
|
|
}
|
|
if len(jobs) == 0 {
|
|
return false
|
|
}
|
|
for _, job := range jobs {
|
|
if ctx.Err() != nil {
|
|
return false
|
|
}
|
|
jobCtx, cancel := context.WithTimeout(ctx, jobBudget)
|
|
i.work(jobCtx, job)
|
|
cancel()
|
|
}
|
|
return true
|
|
}
|
|
|
|
// work is one row, start to finish. Every exit records an outcome, because the row *is*
|
|
// the operator's answer to "why was this item re-read, and did it work".
|
|
func (i *Ingester) work(ctx context.Context, job store.IngestJob) {
|
|
var request IngestRequest
|
|
if err := json.Unmarshal(job.Payload, &request); err != nil {
|
|
i.settle(ctx, job, store.IngestFailed, "invalid", "", err)
|
|
return
|
|
}
|
|
request.Key, request.Action = job.Key, job.Action
|
|
request.Kind, request.Reason = job.Kind, job.Reason
|
|
|
|
cred, err := i.Credentials(ctx)
|
|
if err != nil {
|
|
// Nobody has signed in yet, so there is no way to ask Emby anything. That is a
|
|
// deferral rather than a failure: the work is still valid, it simply cannot be
|
|
// done until a television signs in.
|
|
i.defer_(ctx, job, "no_credentials", err)
|
|
return
|
|
}
|
|
|
|
if job.Action == ActionRemove {
|
|
i.remove(ctx, job, request)
|
|
return
|
|
}
|
|
i.refresh(ctx, job, request, cred)
|
|
}
|
|
|
|
// refresh is the ordinary path: find the item in Emby and write it into the catalogue.
|
|
func (i *Ingester) refresh(
|
|
ctx context.Context, job store.IngestJob, request IngestRequest, cred emby.Credentials,
|
|
) {
|
|
items, itemID, err := i.resolve(ctx, request, cred)
|
|
if err != nil {
|
|
i.defer_(ctx, job, "lookup_failed", err)
|
|
return
|
|
}
|
|
if len(items) == 0 {
|
|
// Emby has not scanned the file in yet, which on a fresh import is the expected
|
|
// first answer rather than a fault. One nudge, then wait: the backoff is what turns
|
|
// "not yet" into "not ever" without a request per minute in between.
|
|
i.nudge(ctx, request, cred)
|
|
i.defer_(ctx, job, "not_found", nil)
|
|
return
|
|
}
|
|
|
|
written := make([]store.LibraryItem, 0, len(items))
|
|
for _, raw := range items {
|
|
if item, ok := toLibraryItem(raw); ok {
|
|
written = append(written, item)
|
|
}
|
|
}
|
|
if len(written) == 0 {
|
|
i.defer_(ctx, job, "not_found", nil)
|
|
return
|
|
}
|
|
// Stamped now, like any other import, so a title written here is never the victim of a
|
|
// full pass that happens to be running.
|
|
if _, err := i.Store.UpsertLibraryItems(ctx, written, time.Now().UTC()); err != nil {
|
|
i.defer_(ctx, job, "write_failed", err)
|
|
return
|
|
}
|
|
i.settle(ctx, job, store.IngestDone, "imported", itemID, nil)
|
|
i.log().Info("arr ingest",
|
|
"event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason,
|
|
"kind", job.Kind, "outcome", "imported", "items", len(written),
|
|
"item", itemID, "attempts", job.Attempts+1)
|
|
// After the row is recorded, never before: the announcement is a claim that the title
|
|
// is in the catalogue, and it must not be made by a pass that then failed to record it.
|
|
i.announce(ctx, job, request, written, itemID)
|
|
}
|
|
|
|
// announce reports a finished scan, if anybody is listening.
|
|
//
|
|
// Whether a given import is worth a banner is deliberately not decided here — that is a
|
|
// question about what viewers should be told, which belongs with the rest of the alert
|
|
// wording. This says what happened; the API package decides what to say about it.
|
|
func (i *Ingester) announce(
|
|
ctx context.Context, job store.IngestJob, request IngestRequest,
|
|
written []store.LibraryItem, itemID string,
|
|
) {
|
|
if i.Announce == nil {
|
|
return
|
|
}
|
|
result := IngestResult{
|
|
Source: job.Source,
|
|
Kind: job.Kind,
|
|
Reason: job.Reason,
|
|
ItemID: itemID,
|
|
SeriesName: request.Series,
|
|
Season: request.Season,
|
|
Episode: request.Episode,
|
|
Name: request.Title,
|
|
Year: request.Year,
|
|
}
|
|
// Emby's own record of the item outranks what the *arr called it: they disagree about
|
|
// punctuation and about years often enough that the banner and the card underneath it
|
|
// would otherwise name the same thing two ways.
|
|
if item, found := findWritten(written, itemID); found {
|
|
result.Name = item.Name
|
|
result.ImageTag = primaryImageTag(item.Payload)
|
|
if item.SeriesName != "" {
|
|
result.SeriesName = item.SeriesName
|
|
}
|
|
if item.ProductionYear != nil {
|
|
result.Year = *item.ProductionYear
|
|
}
|
|
}
|
|
i.Announce(ctx, result)
|
|
}
|
|
|
|
func findWritten(written []store.LibraryItem, itemID string) (store.LibraryItem, bool) {
|
|
if itemID == "" {
|
|
return store.LibraryItem{}, false
|
|
}
|
|
for _, item := range written {
|
|
if item.ID == itemID {
|
|
return item, true
|
|
}
|
|
}
|
|
return store.LibraryItem{}, false
|
|
}
|
|
|
|
// primaryImageTag digs the poster tag out of the payload that was just stored, so a banner
|
|
// can carry artwork without a second lookup. An absent tag is ordinary and costs nothing:
|
|
// the alert simply travels without one.
|
|
func primaryImageTag(payload json.RawMessage) string {
|
|
var parsed struct {
|
|
ImageTags map[string]string `json:"ImageTags"`
|
|
}
|
|
if json.Unmarshal(payload, &parsed) != nil {
|
|
return ""
|
|
}
|
|
return parsed.ImageTags["Primary"]
|
|
}
|
|
|
|
// remove takes a deleted title out of the catalogue.
|
|
//
|
|
// It resolves against the *local* catalogue rather than against Emby, which is the one
|
|
// place in this file that is deliberately the other way round: the thing being removed is
|
|
// a row in Memby's copy, and Emby — having had the file deleted underneath it — is the
|
|
// least likely place to still be able to name it.
|
|
func (i *Ingester) remove(ctx context.Context, job store.IngestJob, request IngestRequest) {
|
|
itemID, err := i.localItemID(ctx, request)
|
|
if err != nil {
|
|
i.defer_(ctx, job, "lookup_failed", err)
|
|
return
|
|
}
|
|
if itemID == "" {
|
|
// Nothing to remove. Ordinary rather than a failure: the catalogue may never have
|
|
// held it, or a previous delivery of this event already did the work.
|
|
i.settle(ctx, job, store.IngestDone, "absent", "", nil)
|
|
i.log().Info("arr ingest",
|
|
"event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason,
|
|
"kind", job.Kind, "outcome", "absent")
|
|
return
|
|
}
|
|
removed, err := i.Store.DeleteLibraryItem(ctx, itemID)
|
|
if err != nil {
|
|
i.defer_(ctx, job, "delete_failed", err)
|
|
return
|
|
}
|
|
i.settle(ctx, job, store.IngestDone, "removed", itemID, nil)
|
|
i.log().Info("arr ingest",
|
|
"event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason,
|
|
"kind", job.Kind, "outcome", "removed", "rows", removed, "item", itemID)
|
|
}
|
|
|
|
// resolve turns what the *arr said into Emby items, narrowly.
|
|
//
|
|
// The second return is the item the work was about, for the log and the console. It is
|
|
// empty for a series-wide refresh, which is about a show rather than about one file.
|
|
func (i *Ingester) resolve(
|
|
ctx context.Context, request IngestRequest, cred emby.Credentials,
|
|
) ([]json.RawMessage, string, error) {
|
|
switch request.Kind {
|
|
case KindMovie:
|
|
return i.resolveMovie(ctx, request, cred)
|
|
case KindEpisode, KindSeries:
|
|
return i.resolveFromSeries(ctx, request, cred)
|
|
}
|
|
return nil, "", fmt.Errorf("library: unknown ingest kind %q", request.Kind)
|
|
}
|
|
|
|
func (i *Ingester) resolveMovie(
|
|
ctx context.Context, request IngestRequest, cred emby.Credentials,
|
|
) ([]json.RawMessage, string, error) {
|
|
page, err := i.Emby.Items(ctx, cred, itemQuery(url.Values{
|
|
"SearchTerm": {request.Title},
|
|
"IncludeItemTypes": {"Movie"},
|
|
"Recursive": {"true"},
|
|
"Limit": {"20"},
|
|
}))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if page == nil {
|
|
return nil, "", nil
|
|
}
|
|
match, id := pickByTitle(page.Items, request.Title, request.Year)
|
|
if match == nil {
|
|
return nil, "", nil
|
|
}
|
|
return []json.RawMessage{match}, id, nil
|
|
}
|
|
|
|
// resolveFromSeries handles both an episode and a whole-series refresh, because they share
|
|
// the expensive half: working out which Emby show this is.
|
|
func (i *Ingester) resolveFromSeries(
|
|
ctx context.Context, request IngestRequest, cred emby.Credentials,
|
|
) ([]json.RawMessage, string, error) {
|
|
seriesID, seriesPayload, err := i.seriesItem(ctx, request, cred)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if seriesID == "" {
|
|
return nil, "", nil
|
|
}
|
|
|
|
params := itemQuery(url.Values{})
|
|
if request.Kind == KindEpisode && request.Season > 0 {
|
|
// One season rather than a show. A long-running series is a thousand records and
|
|
// this runs per imported file.
|
|
params.Set("Season", strconv.Itoa(request.Season))
|
|
}
|
|
page, err := i.Emby.Episodes(ctx, cred, seriesID, params)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
out := make([]json.RawMessage, 0, 8)
|
|
if seriesPayload != nil {
|
|
// A show Emby has only just created has no row here yet, and its episodes would be
|
|
// imported as children of a series the catalogue has never heard of.
|
|
out = append(out, seriesPayload)
|
|
}
|
|
if page == nil {
|
|
return out, "", nil
|
|
}
|
|
if request.Kind == KindSeries {
|
|
// A rename moved files; which files is not something Sonarr says, so the show is
|
|
// the unit of work and one re-read settles all of them.
|
|
return append(out, page.Items...), seriesID, nil
|
|
}
|
|
|
|
for _, raw := range page.Items {
|
|
var parsed struct {
|
|
ID string `json:"Id"`
|
|
IndexNumber *int `json:"IndexNumber"`
|
|
ParentIndexNumber *int `json:"ParentIndexNumber"`
|
|
}
|
|
if json.Unmarshal(raw, &parsed) != nil || parsed.IndexNumber == nil {
|
|
continue
|
|
}
|
|
if *parsed.IndexNumber != request.Episode {
|
|
continue
|
|
}
|
|
if parsed.ParentIndexNumber != nil && *parsed.ParentIndexNumber != request.Season {
|
|
continue
|
|
}
|
|
return append(out, raw), parsed.ID, nil
|
|
}
|
|
// The series is there and the episode is not: Emby has the show but has not scanned the
|
|
// new file. Reporting nothing found keeps that on the deferral path — but the series
|
|
// payload is still worth writing if it was new.
|
|
if len(out) > 0 {
|
|
if _, err := i.Store.UpsertLibraryItems(ctx, seriesItems(out), time.Now().UTC()); err != nil {
|
|
i.log().Debug("could not write the series row ahead of its episode", "error", err)
|
|
}
|
|
}
|
|
return nil, "", nil
|
|
}
|
|
|
|
// seriesItem answers which Emby series this is, preferring the catalogue.
|
|
//
|
|
// The local index is one query the gateway already makes elsewhere and it is right for
|
|
// every show that has ever been imported. Emby is asked only when it misses, which is
|
|
// exactly the case this feature exists for — a brand-new show whose first episode has just
|
|
// landed — and the payload comes back with it so the series row can be written too.
|
|
func (i *Ingester) seriesItem(
|
|
ctx context.Context, request IngestRequest, cred emby.Credentials,
|
|
) (string, json.RawMessage, error) {
|
|
if id := i.localSeriesID(ctx, request.Series, request.SeriesYear); id != "" {
|
|
return id, nil, nil
|
|
}
|
|
page, err := i.Emby.Items(ctx, cred, itemQuery(url.Values{
|
|
"SearchTerm": {request.Series},
|
|
"IncludeItemTypes": {"Series"},
|
|
"Recursive": {"true"},
|
|
"Limit": {"20"},
|
|
}))
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if page == nil {
|
|
return "", nil, nil
|
|
}
|
|
match, id := pickByTitle(page.Items, request.Series, request.SeriesYear)
|
|
return id, match, nil
|
|
}
|
|
|
|
func (i *Ingester) localSeriesID(ctx context.Context, title string, year int) string {
|
|
refs, err := i.Store.SeriesRefs(ctx)
|
|
if err != nil {
|
|
i.log().Debug("series index unavailable for ingest", "error", err)
|
|
return ""
|
|
}
|
|
return matchByTitle(refs, title, year)
|
|
}
|
|
|
|
// localItemID resolves a delete against the catalogue.
|
|
func (i *Ingester) localItemID(ctx context.Context, request IngestRequest) (string, error) {
|
|
switch request.Kind {
|
|
case KindMovie:
|
|
named, err := i.Store.LibraryItemsByName(ctx, "Movie", request.Title)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return matchNamed(named, request.Title, request.Year), nil
|
|
|
|
case KindSeries:
|
|
return i.localSeriesID(ctx, request.Series, request.SeriesYear), nil
|
|
|
|
case KindEpisode:
|
|
seriesID := i.localSeriesID(ctx, request.Series, request.SeriesYear)
|
|
if seriesID == "" {
|
|
return "", nil
|
|
}
|
|
episodes, err := i.Store.CreditsSeriesEpisodes(ctx, []string{seriesID})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, episode := range episodes {
|
|
if episode.Episode == request.Episode && episode.Season == request.Season {
|
|
return episode.ItemID, nil
|
|
}
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// nudge asks Emby to look at the folder the file landed in.
|
|
//
|
|
// Best-effort and deliberately unreported: it is the same trick the subtitle download uses
|
|
// after Bazarr writes a sidecar, and a household whose Emby scans on its own does not need
|
|
// it. Refusing to nudge without a parent is the important half — a refresh of nothing is a
|
|
// request that cannot help.
|
|
func (i *Ingester) nudge(ctx context.Context, request IngestRequest, cred emby.Credentials) {
|
|
if request.Kind == KindMovie {
|
|
return
|
|
}
|
|
seriesID := i.localSeriesID(ctx, request.Series, request.SeriesYear)
|
|
if seriesID == "" {
|
|
return
|
|
}
|
|
if err := i.Emby.RefreshItem(ctx, cred, seriesID); err != nil {
|
|
i.log().Debug("could not ask emby to rescan a series", "series", seriesID, "error", err)
|
|
}
|
|
}
|
|
|
|
// defer_ schedules another attempt, or gives up.
|
|
func (i *Ingester) defer_(ctx context.Context, job store.IngestJob, outcome string, cause error) {
|
|
attempts := job.Attempts + 1
|
|
if attempts >= maxAttempts {
|
|
i.settle(ctx, job, store.IngestFailed, outcome, "", cause)
|
|
i.log().Warn("arr ingest gave up",
|
|
"event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason,
|
|
"kind", job.Kind, "outcome", outcome, "attempts", attempts, "error", errorText(cause))
|
|
return
|
|
}
|
|
retryAt := time.Now().UTC().Add(IngestRetryDelay(attempts))
|
|
if err := i.Store.FinishIngest(
|
|
ctx, job.Key, store.IngestPending, outcome, "", errorText(cause), retryAt,
|
|
); err != nil {
|
|
i.log().Warn("could not reschedule ingest work", "key", job.Key, "error", err)
|
|
}
|
|
i.log().Debug("arr ingest deferred",
|
|
"event", "arr_ingest", "key", job.Key, "reason", job.Reason, "outcome", outcome,
|
|
"attempts", attempts, "retry_in", IngestRetryDelay(attempts).String(),
|
|
"error", errorText(cause))
|
|
}
|
|
|
|
func (i *Ingester) settle(
|
|
ctx context.Context, job store.IngestJob, state, outcome, itemID string, cause error,
|
|
) {
|
|
// Detached from the job's own budget: a row that timed out must still record that it
|
|
// did, or the next pass claims it again immediately and the backoff never applies.
|
|
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
|
defer cancel()
|
|
if err := i.Store.FinishIngest(
|
|
writeCtx, job.Key, state, outcome, itemID, errorText(cause), time.Now().UTC(),
|
|
); err != nil {
|
|
i.log().Warn("could not record ingest outcome", "key", job.Key, "error", err)
|
|
}
|
|
}
|
|
|
|
// IngestRetryDelay is the backoff, and it is a step function rather than an exponent so
|
|
// the schedule can be read off the page: a minute, five, twenty, an hour, then four-hourly
|
|
// out to the attempt limit. The early steps are short because the common cause is Emby not
|
|
// having scanned yet, which resolves in minutes; the late ones are long because by then the
|
|
// cause is something a faster retry cannot fix.
|
|
func IngestRetryDelay(attempts int) time.Duration {
|
|
switch {
|
|
case attempts <= 1:
|
|
return time.Minute
|
|
case attempts == 2:
|
|
return 5 * time.Minute
|
|
case attempts == 3:
|
|
return 20 * time.Minute
|
|
case attempts == 4:
|
|
return time.Hour
|
|
default:
|
|
return 4 * time.Hour
|
|
}
|
|
}
|
|
|
|
// itemQuery is the field set every lookup here uses, and it is deliberately the scheduled
|
|
// import's own.
|
|
//
|
|
// Thinning it would leave an event-imported title without People, MediaStreams or
|
|
// ProviderIds — so no cast on its page, no ratings lookup and no format badges — until Emby
|
|
// next reported it changed, which for a film nobody edits again is never. Syncer.Find makes
|
|
// the same promise for the same reason.
|
|
func itemQuery(params url.Values) url.Values {
|
|
params.Set("Fields", syncFields)
|
|
params.Set("ImageTypeLimit", "1")
|
|
params.Set("EnableImages", "true")
|
|
params.Set("EnableImageTypes", syncImageTypes)
|
|
params.Set("EnableTotalRecordCount", "false")
|
|
params.Set("EnableUserData", "false")
|
|
return params
|
|
}
|
|
|
|
// pickByTitle chooses the item a title and year names.
|
|
//
|
|
// Year-qualified first and title-only as the fallback, the rule the schedule row's series
|
|
// index already applies: an *arr and Emby disagree about a show's year far more often than
|
|
// they disagree about its name, but where both know the year it is what separates a remake
|
|
// from its original.
|
|
func pickByTitle(items []json.RawMessage, title string, year int) (json.RawMessage, string) {
|
|
want := NormalizedTitle(title)
|
|
if want == "" {
|
|
return nil, ""
|
|
}
|
|
var fallback json.RawMessage
|
|
var fallbackID string
|
|
for _, raw := range items {
|
|
var parsed struct {
|
|
ID string `json:"Id"`
|
|
Name string `json:"Name"`
|
|
ProductionYear *int `json:"ProductionYear"`
|
|
}
|
|
if json.Unmarshal(raw, &parsed) != nil || parsed.ID == "" {
|
|
continue
|
|
}
|
|
if NormalizedTitle(parsed.Name) != want {
|
|
continue
|
|
}
|
|
if year > 0 && parsed.ProductionYear != nil && *parsed.ProductionYear == year {
|
|
return raw, parsed.ID
|
|
}
|
|
if fallback == nil {
|
|
fallback, fallbackID = raw, parsed.ID
|
|
}
|
|
}
|
|
return fallback, fallbackID
|
|
}
|
|
|
|
func matchByTitle(refs []store.SeriesRef, title string, year int) string {
|
|
want := NormalizedTitle(title)
|
|
if want == "" {
|
|
return ""
|
|
}
|
|
fallback := ""
|
|
for _, ref := range refs {
|
|
if NormalizedTitle(ref.Name) != want {
|
|
continue
|
|
}
|
|
if year > 0 && ref.Year == year {
|
|
return ref.ID
|
|
}
|
|
if fallback == "" {
|
|
fallback = ref.ID
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func matchNamed(items []store.NamedItem, title string, year int) string {
|
|
want := NormalizedTitle(title)
|
|
if want == "" {
|
|
return ""
|
|
}
|
|
fallback := ""
|
|
for _, item := range items {
|
|
if NormalizedTitle(item.Name) != want {
|
|
continue
|
|
}
|
|
if year > 0 && item.Year == year {
|
|
return item.ID
|
|
}
|
|
if fallback == "" {
|
|
fallback = item.ID
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// seriesItems is the series payload on its own, for the case where the episode has not
|
|
// appeared yet but the show has.
|
|
func seriesItems(payloads []json.RawMessage) []store.LibraryItem {
|
|
out := make([]store.LibraryItem, 0, len(payloads))
|
|
for _, raw := range payloads {
|
|
if item, ok := toLibraryItem(raw); ok && item.Type == "Series" {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func errorText(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(err.Error())
|
|
}
|
|
|
|
func sleep(ctx context.Context, duration time.Duration) bool {
|
|
timer := time.NewTimer(duration)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-timer.C:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Enqueue records work a webhook implied, and answers how much of it was news.
|
|
//
|
|
// It is the hook's whole job. Everything expensive happens later, on the worker, which is
|
|
// what lets the hook answer Sonarr in a millisecond and — more importantly — what lets it
|
|
// answer at all during quiet hours, when the work itself must wait.
|
|
func (i *Ingester) Enqueue(
|
|
ctx context.Context, source string, requests []IngestRequest,
|
|
) (int, error) {
|
|
if i == nil || i.Store == nil || len(requests) == 0 {
|
|
return 0, nil
|
|
}
|
|
due := time.Now().UTC().Add(i.SettleDelay())
|
|
fresh := 0
|
|
var firstErr error
|
|
for _, request := range requests {
|
|
payload, err := json.Marshal(request)
|
|
if err != nil {
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
inserted, err := i.Store.EnqueueIngest(ctx, store.IngestJob{
|
|
Key: request.Key,
|
|
Action: request.Action,
|
|
Kind: request.Kind,
|
|
Reason: request.Reason,
|
|
Source: source,
|
|
Payload: payload,
|
|
DueAt: due,
|
|
})
|
|
if err != nil {
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
if inserted {
|
|
fresh++
|
|
continue
|
|
}
|
|
// A repeat delivery is ordinary — both *arrs re-notify on retry — so it is DEBUG,
|
|
// the same stance the per-keystroke search line takes.
|
|
i.log().Debug("arr ingest already queued",
|
|
"event", "arr_ingest", "key", request.Key, "source", source, "reason", request.Reason)
|
|
}
|
|
return fresh, firstErr
|
|
}
|