508 lines
17 KiB
Go
508 lines
17 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/emby"
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
// fakeStore is the queue and the catalogue as maps. Everything the worker does is visible
|
|
// in it, which is the point of the store being an interface here.
|
|
type fakeStore struct {
|
|
jobs map[string]*store.IngestJob
|
|
order []string
|
|
items map[string]store.LibraryItem
|
|
deleted []string
|
|
series []store.SeriesRef
|
|
episodes []store.CreditsEpisodeRow
|
|
named []store.NamedItem
|
|
failNext error
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{jobs: map[string]*store.IngestJob{}, items: map[string]store.LibraryItem{}}
|
|
}
|
|
|
|
func (f *fakeStore) EnqueueIngest(_ context.Context, job store.IngestJob) (bool, error) {
|
|
existing, found := f.jobs[job.Key]
|
|
if found {
|
|
// The real table's ON CONFLICT: one row, and a re-delivery never pulls the settle
|
|
// delay forward.
|
|
if job.DueAt.After(existing.DueAt) {
|
|
existing.DueAt = job.DueAt
|
|
}
|
|
existing.State = store.IngestPending
|
|
return false, nil
|
|
}
|
|
stored := job
|
|
stored.State = store.IngestPending
|
|
f.jobs[job.Key] = &stored
|
|
f.order = append(f.order, job.Key)
|
|
return true, nil
|
|
}
|
|
|
|
func (f *fakeStore) ClaimIngest(_ context.Context, now time.Time, limit int) ([]store.IngestJob, error) {
|
|
out := []store.IngestJob{}
|
|
for _, key := range f.order {
|
|
job := f.jobs[key]
|
|
if job.State != store.IngestPending || job.DueAt.After(now) {
|
|
continue
|
|
}
|
|
out = append(out, *job)
|
|
if len(out) == limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) FinishIngest(
|
|
_ context.Context, key, state, outcome, itemID, errorText string, retryAt time.Time,
|
|
) error {
|
|
job, found := f.jobs[key]
|
|
if !found {
|
|
return errors.New("no such job")
|
|
}
|
|
job.State, job.Outcome, job.ItemID = state, outcome, itemID
|
|
job.LastError, job.DueAt = errorText, retryAt
|
|
job.Attempts++
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) UpsertLibraryItems(
|
|
_ context.Context, items []store.LibraryItem, _ time.Time,
|
|
) (int64, error) {
|
|
if f.failNext != nil {
|
|
err := f.failNext
|
|
f.failNext = nil
|
|
return 0, err
|
|
}
|
|
for _, item := range items {
|
|
f.items[item.ID] = item
|
|
}
|
|
return int64(len(items)), nil
|
|
}
|
|
|
|
func (f *fakeStore) DeleteLibraryItem(_ context.Context, itemID string) (int64, error) {
|
|
f.deleted = append(f.deleted, itemID)
|
|
delete(f.items, itemID)
|
|
return 1, nil
|
|
}
|
|
|
|
func (f *fakeStore) SeriesRefs(context.Context) ([]store.SeriesRef, error) { return f.series, nil }
|
|
|
|
func (f *fakeStore) CreditsSeriesEpisodes(
|
|
context.Context, []string,
|
|
) ([]store.CreditsEpisodeRow, error) {
|
|
return f.episodes, nil
|
|
}
|
|
|
|
func (f *fakeStore) LibraryItemsByName(
|
|
_ context.Context, _, _ string,
|
|
) ([]store.NamedItem, error) {
|
|
return f.named, nil
|
|
}
|
|
|
|
// fakeEmby answers the two lookups and counts the nudges.
|
|
type fakeEmby struct {
|
|
items []json.RawMessage
|
|
episodes []json.RawMessage
|
|
refreshed []string
|
|
itemQueries []url.Values
|
|
seasons []string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeEmby) Items(
|
|
_ context.Context, _ emby.Credentials, params url.Values,
|
|
) (*emby.ItemsResult, error) {
|
|
f.itemQueries = append(f.itemQueries, params)
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return &emby.ItemsResult{Items: f.items}, nil
|
|
}
|
|
|
|
func (f *fakeEmby) Episodes(
|
|
_ context.Context, _ emby.Credentials, _ string, params url.Values,
|
|
) (*emby.ItemsResult, error) {
|
|
f.seasons = append(f.seasons, params.Get("Season"))
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return &emby.ItemsResult{Items: f.episodes}, nil
|
|
}
|
|
|
|
func (f *fakeEmby) RefreshItem(_ context.Context, _ emby.Credentials, itemID string) error {
|
|
f.refreshed = append(f.refreshed, itemID)
|
|
return nil
|
|
}
|
|
|
|
func testIngester(st *fakeStore, source *fakeEmby) *Ingester {
|
|
return &Ingester{
|
|
Store: st,
|
|
Emby: source,
|
|
Credentials: func(context.Context) (emby.Credentials, error) {
|
|
return emby.Credentials{UserID: "u", Token: "t"}, nil
|
|
},
|
|
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
Settle: time.Minute,
|
|
}
|
|
}
|
|
|
|
func episodePayload(id string, season, episode int) json.RawMessage {
|
|
raw, _ := json.Marshal(map[string]any{
|
|
"Id": id, "Name": "The Job", "Type": "Episode", "SeriesId": "emby-series",
|
|
"ParentIndexNumber": season, "IndexNumber": episode,
|
|
})
|
|
return raw
|
|
}
|
|
|
|
func enqueueOne(t *testing.T, ingester *Ingester, request IngestRequest) {
|
|
t.Helper()
|
|
if _, err := ingester.Enqueue(context.Background(), "sonarr", []IngestRequest{request}); err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
}
|
|
|
|
func episodeRequest() IngestRequest {
|
|
return IngestRequest{
|
|
Key: "sonarr:episodefile:8123:551", Action: ActionRefresh, Kind: KindEpisode,
|
|
Reason: ReasonImport, Series: "Blue Bloods", SeriesYear: 2010, Season: 6, Episode: 7,
|
|
}
|
|
}
|
|
|
|
// The ordinary path: the series is already in the catalogue, so Emby is asked for one
|
|
// season and the episode is written.
|
|
func TestImportWritesTheEpisodeFromOneSeasonLookup(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
|
|
source := &fakeEmby{episodes: []json.RawMessage{
|
|
episodePayload("emby-ep-6", 6, 6),
|
|
episodePayload("emby-ep-7", 6, 7),
|
|
}}
|
|
ingester := testIngester(st, source)
|
|
enqueueOne(t, ingester, episodeRequest())
|
|
|
|
ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"])
|
|
|
|
if _, written := st.items["emby-ep-7"]; !written {
|
|
t.Fatalf("the episode was not written: %v", st.items)
|
|
}
|
|
if _, extra := st.items["emby-ep-6"]; extra {
|
|
t.Fatal("an episode nobody asked about was written")
|
|
}
|
|
if len(source.seasons) != 1 || source.seasons[0] != "6" {
|
|
t.Fatalf("expected one season-scoped lookup, got %v", source.seasons)
|
|
}
|
|
// A series already in the catalogue costs no search at all.
|
|
if len(source.itemQueries) != 0 {
|
|
t.Fatalf("the catalogue was not used for the series: %v", source.itemQueries)
|
|
}
|
|
job := st.jobs["sonarr:episodefile:8123:551"]
|
|
if job.State != store.IngestDone || job.Outcome != "imported" {
|
|
t.Fatalf("unexpected outcome: %+v", job)
|
|
}
|
|
}
|
|
|
|
// The field set must be the scheduled import's own, or an event-imported title arrives
|
|
// without the cast, streams and provider ids everything downstream reads.
|
|
func TestLookupsAskForTheFullSyncFields(t *testing.T) {
|
|
st := newFakeStore()
|
|
source := &fakeEmby{}
|
|
ingester := testIngester(st, source)
|
|
enqueueOne(t, ingester, IngestRequest{
|
|
Key: "radarr:moviefile:441:import", Action: ActionRefresh, Kind: KindMovie,
|
|
Reason: ReasonImport, Title: "Arrival", Year: 2016,
|
|
})
|
|
ingester.work(context.Background(), *st.jobs["radarr:moviefile:441:import"])
|
|
|
|
if len(source.itemQueries) != 1 {
|
|
t.Fatalf("expected one lookup, got %d", len(source.itemQueries))
|
|
}
|
|
query := source.itemQueries[0]
|
|
if query.Get("Fields") != syncFields {
|
|
t.Fatalf("a thinner field set was requested: %q", query.Get("Fields"))
|
|
}
|
|
if query.Get("EnableUserData") != "false" {
|
|
t.Fatal("the shared catalogue must never carry one viewer's user data")
|
|
}
|
|
}
|
|
|
|
// Emby not having scanned the file yet is the expected first answer, not a fault: the row
|
|
// waits, one nudge is sent, and the backoff widens.
|
|
func TestNotFoundDefersWithABackoffRatherThanFailing(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
|
|
source := &fakeEmby{}
|
|
ingester := testIngester(st, source)
|
|
enqueueOne(t, ingester, episodeRequest())
|
|
key := "sonarr:episodefile:8123:551"
|
|
|
|
before := time.Now().UTC()
|
|
ingester.work(context.Background(), *st.jobs[key])
|
|
|
|
job := st.jobs[key]
|
|
if job.State != store.IngestPending || job.Outcome != "not_found" {
|
|
t.Fatalf("expected a deferral, got %+v", job)
|
|
}
|
|
if !job.DueAt.After(before) {
|
|
t.Fatal("the next attempt was not scheduled into the future")
|
|
}
|
|
if len(source.refreshed) != 1 || source.refreshed[0] != "emby-series" {
|
|
t.Fatalf("expected one rescan nudge at the series, got %v", source.refreshed)
|
|
}
|
|
if len(st.items) != 0 {
|
|
t.Fatal("nothing should have been written")
|
|
}
|
|
}
|
|
|
|
// Attempts are given up on eventually, because past the last step of the backoff the cause
|
|
// is not timing and a row retrying for ever is one nobody looks at.
|
|
func TestRepeatedFailureIsEventuallyGivenUpOn(t *testing.T) {
|
|
st := newFakeStore()
|
|
source := &fakeEmby{err: errors.New("emby is not answering")}
|
|
ingester := testIngester(st, source)
|
|
enqueueOne(t, ingester, episodeRequest())
|
|
key := "sonarr:episodefile:8123:551"
|
|
|
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
|
ingester.work(context.Background(), *st.jobs[key])
|
|
}
|
|
if st.jobs[key].State != store.IngestFailed {
|
|
t.Fatalf("expected the row to be given up on, got %+v", st.jobs[key])
|
|
}
|
|
if st.jobs[key].LastError == "" {
|
|
t.Fatal("a failed row must record why")
|
|
}
|
|
}
|
|
|
|
func TestRetryDelayWidensAndSettles(t *testing.T) {
|
|
previous := time.Duration(0)
|
|
for attempt := 1; attempt <= 6; attempt++ {
|
|
delay := IngestRetryDelay(attempt)
|
|
if delay < previous {
|
|
t.Fatalf("the backoff narrowed at attempt %d: %s after %s", attempt, delay, previous)
|
|
}
|
|
previous = delay
|
|
}
|
|
if IngestRetryDelay(1) != time.Minute {
|
|
t.Fatalf("the first retry should be quick, got %s", IngestRetryDelay(1))
|
|
}
|
|
}
|
|
|
|
// A delete resolves against the catalogue, not against Emby: the file is gone, and Emby is
|
|
// the least likely thing to still be able to name it.
|
|
func TestDeleteRemovesTheEpisodeFromTheCatalogue(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
|
|
st.episodes = []store.CreditsEpisodeRow{
|
|
{ItemID: "emby-ep-6", SeriesID: "emby-series", Season: 6, Episode: 6},
|
|
{ItemID: "emby-ep-7", SeriesID: "emby-series", Season: 6, Episode: 7},
|
|
}
|
|
source := &fakeEmby{}
|
|
ingester := testIngester(st, source)
|
|
request := episodeRequest()
|
|
request.Action, request.Reason, request.Key = ActionRemove, ReasonDelete, "sonarr:episodefile:8123:delete"
|
|
enqueueOne(t, ingester, request)
|
|
|
|
ingester.work(context.Background(), *st.jobs[request.Key])
|
|
|
|
if len(st.deleted) != 1 || st.deleted[0] != "emby-ep-7" {
|
|
t.Fatalf("unexpected deletions: %v", st.deleted)
|
|
}
|
|
if len(source.itemQueries) != 0 || len(source.seasons) != 0 {
|
|
t.Fatal("a delete must not need to ask Emby anything")
|
|
}
|
|
}
|
|
|
|
// A delete of something the catalogue never held is settled rather than retried: there is
|
|
// nothing to remove and no later attempt could change that.
|
|
func TestDeleteOfSomethingAbsentSettlesQuietly(t *testing.T) {
|
|
st := newFakeStore()
|
|
ingester := testIngester(st, &fakeEmby{})
|
|
request := IngestRequest{
|
|
Key: "radarr:moviefile:9:delete", Action: ActionRemove, Kind: KindMovie,
|
|
Reason: ReasonDelete, Title: "Never Imported", Year: 1999,
|
|
}
|
|
enqueueOne(t, ingester, request)
|
|
ingester.work(context.Background(), *st.jobs[request.Key])
|
|
|
|
job := st.jobs[request.Key]
|
|
if job.State != store.IngestDone || job.Outcome != "absent" {
|
|
t.Fatalf("expected a quiet settle, got %+v", job)
|
|
}
|
|
if len(st.deleted) != 0 {
|
|
t.Fatalf("something was deleted: %v", st.deleted)
|
|
}
|
|
}
|
|
|
|
// A brand-new show is the case the local index cannot answer, and it is exactly the case
|
|
// this feature exists for. Emby is asked, and the series row is written beside its episode
|
|
// so the episode is not a child of a show the catalogue has never heard of.
|
|
func TestANewSeriesIsResolvedThroughEmbyAndWrittenToo(t *testing.T) {
|
|
st := newFakeStore()
|
|
seriesRaw, _ := json.Marshal(map[string]any{
|
|
"Id": "emby-series", "Name": "Blue Bloods", "Type": "Series", "ProductionYear": 2010,
|
|
})
|
|
source := &fakeEmby{
|
|
items: []json.RawMessage{seriesRaw},
|
|
episodes: []json.RawMessage{episodePayload("emby-ep-7", 6, 7)},
|
|
}
|
|
ingester := testIngester(st, source)
|
|
enqueueOne(t, ingester, episodeRequest())
|
|
ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"])
|
|
|
|
if _, written := st.items["emby-series"]; !written {
|
|
t.Fatalf("the new series row was not written: %v", st.items)
|
|
}
|
|
if _, written := st.items["emby-ep-7"]; !written {
|
|
t.Fatal("the episode was not written")
|
|
}
|
|
}
|
|
|
|
// The year separates a remake from its original where both systems know it, and the title
|
|
// alone is the fallback because they disagree about years more often than about names.
|
|
func TestMovieMatchingPrefersTheYearAndFallsBackToTheTitle(t *testing.T) {
|
|
original, _ := json.Marshal(map[string]any{
|
|
"Id": "old", "Name": "The Thing", "Type": "Movie", "ProductionYear": 1982,
|
|
})
|
|
remake, _ := json.Marshal(map[string]any{
|
|
"Id": "new", "Name": "The Thing", "Type": "Movie", "ProductionYear": 2011,
|
|
})
|
|
items := []json.RawMessage{original, remake}
|
|
|
|
if _, id := pickByTitle(items, "The Thing", 2011); id != "new" {
|
|
t.Fatalf("the year did not decide: %q", id)
|
|
}
|
|
if _, id := pickByTitle(items, "The Thing", 0); id != "old" {
|
|
t.Fatalf("expected the first title match as the fallback, got %q", id)
|
|
}
|
|
if _, id := pickByTitle(items, "Something Else", 0); id != "" {
|
|
t.Fatalf("an unrelated title matched: %q", id)
|
|
}
|
|
}
|
|
|
|
// A repeated webhook is one row, and it never pulls the settle delay forward — the whole
|
|
// point of the delay is that the file has finished being written.
|
|
func TestRepeatedEnqueueIsOneRowAndKeepsTheSettleDelay(t *testing.T) {
|
|
st := newFakeStore()
|
|
ingester := testIngester(st, &fakeEmby{})
|
|
request := episodeRequest()
|
|
|
|
fresh, err := ingester.Enqueue(context.Background(), "sonarr", []IngestRequest{request})
|
|
if err != nil || fresh != 1 {
|
|
t.Fatalf("first delivery: fresh=%d err=%v", fresh, err)
|
|
}
|
|
first := st.jobs[request.Key].DueAt
|
|
|
|
fresh, err = ingester.Enqueue(context.Background(), "sonarr", []IngestRequest{request})
|
|
if err != nil || fresh != 0 {
|
|
t.Fatalf("a repeat was treated as news: fresh=%d err=%v", fresh, err)
|
|
}
|
|
if len(st.jobs) != 1 {
|
|
t.Fatalf("a repeat produced %d rows", len(st.jobs))
|
|
}
|
|
if st.jobs[request.Key].DueAt.Before(first) {
|
|
t.Fatal("a repeat pulled the settle delay forward")
|
|
}
|
|
}
|
|
|
|
// Quiet time stands the worker down without touching the queue, which is the arrangement
|
|
// that lets the hook accept an event at any hour.
|
|
func TestQuietTimeStopsTheWorkerAndNotTheQueue(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
|
|
source := &fakeEmby{episodes: []json.RawMessage{episodePayload("emby-ep-7", 6, 7)}}
|
|
ingester := testIngester(st, source)
|
|
ingester.Paused = func() bool { return true }
|
|
enqueueOne(t, ingester, episodeRequest())
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { ingester.Run(ctx); close(done) }()
|
|
time.Sleep(50 * time.Millisecond)
|
|
cancel()
|
|
<-done
|
|
|
|
if len(st.items) != 0 {
|
|
t.Fatal("work was done during quiet time")
|
|
}
|
|
if st.jobs["sonarr:episodefile:8123:551"].State != store.IngestPending {
|
|
t.Fatal("the queued work was lost rather than deferred")
|
|
}
|
|
}
|
|
|
|
// A finished scan is the moment there is something truthful to announce, which is why the
|
|
// hook fires from here rather than from the webhook. It must carry Emby's own names: the
|
|
// *arr and Emby disagree about punctuation often enough that a banner built from the
|
|
// webhook would name the same thing differently from the card underneath it.
|
|
func TestAFinishedImportIsAnnouncedWithEmbysOwnNames(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
|
|
source := &fakeEmby{episodes: []json.RawMessage{episodeWithArtwork("emby-ep-7", 6, 7)}}
|
|
ingester := testIngester(st, source)
|
|
|
|
var announced []IngestResult
|
|
ingester.Announce = func(_ context.Context, result IngestResult) {
|
|
announced = append(announced, result)
|
|
}
|
|
enqueueOne(t, ingester, episodeRequest())
|
|
ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"])
|
|
|
|
if len(announced) != 1 {
|
|
t.Fatalf("expected one announcement, got %d", len(announced))
|
|
}
|
|
result := announced[0]
|
|
if result.ItemID != "emby-ep-7" || result.Name != "The Job" {
|
|
t.Errorf("announced %q/%q, want Emby's id and episode title", result.ItemID, result.Name)
|
|
}
|
|
if result.SeriesName != "Blue Bloods" {
|
|
t.Errorf("series = %q, want Emby's series name", result.SeriesName)
|
|
}
|
|
if result.Season != 6 || result.Episode != 7 {
|
|
t.Errorf("position = S%02dE%02d, want S06E07", result.Season, result.Episode)
|
|
}
|
|
if result.ImageTag != "poster-tag" {
|
|
t.Errorf("image tag = %q, want the poster from the stored payload", result.ImageTag)
|
|
}
|
|
if result.Reason != ReasonImport || result.Kind != KindEpisode {
|
|
t.Errorf("result = %+v, want the import reason and kind carried through", result)
|
|
}
|
|
}
|
|
|
|
// Nothing is announced for work that did not land. The banner claims the title is in the
|
|
// catalogue, so a lookup that found nothing must stay silent and simply be retried.
|
|
func TestNothingIsAnnouncedWhenEmbyHasNotScannedYet(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
|
|
ingester := testIngester(st, &fakeEmby{})
|
|
|
|
announcements := 0
|
|
ingester.Announce = func(context.Context, IngestResult) { announcements++ }
|
|
enqueueOne(t, ingester, episodeRequest())
|
|
ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"])
|
|
|
|
if announcements != 0 {
|
|
t.Fatalf("announced %d times for an episode Emby has not scanned", announcements)
|
|
}
|
|
}
|
|
|
|
func episodeWithArtwork(id string, season, episode int) json.RawMessage {
|
|
raw, _ := json.Marshal(map[string]any{
|
|
"Id": id, "Name": "The Job", "Type": "Episode", "SeriesId": "emby-series",
|
|
"SeriesName": "Blue Bloods",
|
|
"ParentIndexNumber": season, "IndexNumber": episode,
|
|
"ImageTags": map[string]string{"Primary": "poster-tag"},
|
|
})
|
|
return raw
|
|
}
|