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

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
}