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 }