From e470760ab48f72389eb545ffe074941cd0f67cfa Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 22:57:44 -0400 Subject: [PATCH] fix(requests): hydrate external ids before availability --- internal/requests/presence.go | 2 +- internal/requests/service.go | 99 ++++++++++++++++++++---- internal/requests/service_test.go | 120 ++++++++++++++++++++++++++++-- 3 files changed, 201 insertions(+), 20 deletions(-) diff --git a/internal/requests/presence.go b/internal/requests/presence.go index b792c1b5..ad1224e0 100644 --- a/internal/requests/presence.go +++ b/internal/requests/presence.go @@ -22,7 +22,7 @@ type PresenceMatch struct { } type PresenceResolver interface { - LookupTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]bool, error) + Lookup(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) } type presenceItemLookup interface { diff --git a/internal/requests/service.go b/internal/requests/service.go index 00861278..4bffc775 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -247,11 +247,13 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create if err != nil { return nil, err } - available, err := s.lookupAvailable(ctx, normalized.MediaType, []int{normalized.TMDBID}) + s.enrichExternalIDs(ctx, &normalized) + + matches, err := s.lookupPresence(ctx, normalized.MediaType, []PresenceCandidate{createPresenceCandidate(normalized)}) if err != nil { return nil, err } - if available[normalized.TMDBID] { + if matches[normalized.TMDBID].Available { return nil, ErrAlreadyAvailable } @@ -271,8 +273,6 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create return nil, err } - s.enrichExternalIDs(ctx, &normalized) - id, err := idgen.NextID() if err != nil { return nil, err @@ -672,11 +672,86 @@ func (s *Service) enrichPage(ctx context.Context, viewer Viewer, raw *tmdb.Media return out, nil } -func (s *Service) lookupAvailable(ctx context.Context, mediaType MediaType, ids []int) (map[int]bool, error) { +func (s *Service) lookupPresence(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) { if s.presence == nil { - return map[int]bool{}, nil + return map[int]PresenceMatch{}, nil } - return s.presence.LookupTMDB(ctx, mediaType, ids) + return s.presence.Lookup(ctx, mediaType, candidates) +} + +func availabilityBoolMap(matches map[int]PresenceMatch) map[int]bool { + out := map[int]bool{} + for id, match := range matches { + out[id] = match.Available + } + return out +} + +func requestPresenceCandidate(req Request) PresenceCandidate { + candidate := PresenceCandidate{ + TMDBID: req.TMDBID, + IMDbID: strings.TrimSpace(req.IMDbID), + } + if req.TVDBID != nil && *req.TVDBID > 0 { + tvdbID := *req.TVDBID + candidate.TVDBID = &tvdbID + } + return candidate +} + +func createPresenceCandidate(input CreateRequestInput) PresenceCandidate { + candidate := PresenceCandidate{ + TMDBID: input.TMDBID, + IMDbID: strings.TrimSpace(input.IMDbID), + } + if input.TVDBID != nil && *input.TVDBID > 0 { + tvdbID := *input.TVDBID + candidate.TVDBID = &tvdbID + } + return candidate +} + +func (s *Service) hydratePresenceCandidate(ctx context.Context, mediaType MediaType, candidate PresenceCandidate) PresenceCandidate { + if candidate.TMDBID <= 0 { + return candidate + } + client, ok := s.tmdb.(TMDBExternalIDClient) + if !ok { + return candidate + } + externalIDs, err := client.GetExternalIDs(ctx, tmdbMediaType(mediaType), candidate.TMDBID) + if err != nil || externalIDs == nil { + return candidate + } + if candidate.IMDbID == "" { + candidate.IMDbID = strings.TrimSpace(externalIDs.IMDbID) + } + if candidate.TVDBID == nil && externalIDs.TVDBID > 0 { + tvdbID := externalIDs.TVDBID + candidate.TVDBID = &tvdbID + } + return candidate +} + +func tmdbMediaType(mediaType MediaType) string { + if mediaType == MediaTypeSeries { + return "tv" + } + return "movie" +} + +func (s *Service) lookupAvailable(ctx context.Context, mediaType MediaType, ids []int) (map[int]bool, error) { + candidates := make([]PresenceCandidate, 0, len(ids)) + for _, id := range ids { + if id > 0 { + candidates = append(candidates, s.hydratePresenceCandidate(ctx, mediaType, PresenceCandidate{TMDBID: id})) + } + } + matches, err := s.lookupPresence(ctx, mediaType, candidates) + if err != nil { + return nil, err + } + return availabilityBoolMap(matches), nil } func (s *Service) enrichExternalIDs(ctx context.Context, input *CreateRequestInput) { @@ -687,11 +762,7 @@ func (s *Service) enrichExternalIDs(ctx context.Context, input *CreateRequestInp if !ok { return } - mediaType := "movie" - if input.MediaType == MediaTypeSeries { - mediaType = "tv" - } - externalIDs, err := client.GetExternalIDs(ctx, mediaType, input.TMDBID) + externalIDs, err := client.GetExternalIDs(ctx, tmdbMediaType(input.MediaType), input.TMDBID) if err != nil || externalIDs == nil { return } @@ -864,11 +935,11 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC } func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, error) { - available, err := s.lookupAvailable(ctx, req.MediaType, []int{req.TMDBID}) + matches, err := s.lookupPresence(ctx, req.MediaType, []PresenceCandidate{requestPresenceCandidate(req)}) if err != nil { return false, err } - return available[req.TMDBID], nil + return matches[req.TMDBID].Available, nil } func (s *Service) checkFulfillmentStatus(ctx context.Context, req Request) (FulfillmentStatus, error) { diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 99773e17..a5bc39a8 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -229,6 +229,28 @@ func TestCreateRequestEnrichesSeriesTVDBID(t *testing.T) { } } +func TestCreateRequestBlocksWhenHydratedTVDBIDIsAvailable(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + tmdbClient := &fakeTMDBClient{externalIDs: &tmdb.ExternalIDs{TVDBID: 420105, IMDbID: "tt18076310"}} + presence := &fakePresence{byTVDB: map[MediaType]map[int]int{ + MediaTypeSeries: {420105: 201992}, + }} + service := NewService(store, tmdbClient, presence) + + _, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeSeries, + TMDBID: 201992, + Title: "The Rookie: Feds", + }) + if !errors.Is(err, ErrAlreadyAvailable) { + t.Fatalf("err = %v, want ErrAlreadyAvailable", err) + } + if len(store.created) != 0 { + t.Fatalf("created requests = %d, want 0", len(store.created)) + } +} + func TestCreateRequestNoActiveDuplicateCreatesRequest(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true @@ -250,6 +272,43 @@ func TestCreateRequestNoActiveDuplicateCreatesRequest(t *testing.T) { } } +func TestSearchMarksSeriesAvailableByHydratedTVDBID(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + tmdbClient := &fakeTMDBClient{ + page: &tmdb.MediaPage{ + Page: 1, + Results: []tmdb.MediaResult{{ + ID: 201992, + MediaType: "series", + Title: "The Rookie: Feds", + Year: 2022, + }}, + }, + externalIDsByID: map[int]*tmdb.ExternalIDs{ + 201992: {TVDBID: 420105, IMDbID: "tt18076310"}, + }, + } + presence := &fakePresence{byTVDB: map[MediaType]map[int]int{ + MediaTypeSeries: {420105: 201992}, + }} + service := NewService(store, tmdbClient, presence) + + page, err := service.Search(context.Background(), testViewer(1), "rookie feds", MediaTypeSeries, 1) + if err != nil { + t.Fatalf("Search returned error: %v", err) + } + if got := page.Results[0].Availability; got != AvailabilityAvailable { + t.Fatalf("availability = %q, want available", got) + } + if page.Results[0].Request.Reason != "already_available" { + t.Fatalf("request reason = %q, want already_available", page.Results[0].Request.Reason) + } + if len(presence.got) != 1 || presence.got[0].TVDBID == nil || *presence.got[0].TVDBID != 420105 { + t.Fatalf("presence candidates = %+v, want hydrated tvdb id", presence.got) + } +} + func TestSearchEnrichmentHidesOtherRequesterID(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true @@ -395,6 +454,31 @@ func TestReconcileRequestsCompletesFromCatalogPresence(t *testing.T) { } } +func TestReconcileRequestsCompletesByStoredTVDBID(t *testing.T) { + store := newFakeStore() + tvdbID := 420105 + store.candidates = []*Request{{ + ID: "req-1", + MediaType: MediaTypeSeries, + TMDBID: 201992, + TVDBID: &tvdbID, + Status: StatusQueued, + Outcome: OutcomeActive, + }} + presence := &fakePresence{byTVDB: map[MediaType]map[int]int{ + MediaTypeSeries: {420105: 201992}, + }} + service := NewService(store, &fakeTMDBClient{}, presence) + + result, err := service.ReconcileRequests(context.Background(), 100) + if err != nil { + t.Fatalf("ReconcileRequests returned error: %v", err) + } + if result.Completed != 1 { + t.Fatalf("completed = %d, want 1", result.Completed) + } +} + func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) { store := newFakeStore() qualityProfileID := 1 @@ -843,15 +927,35 @@ func TestBrowseGenreMovieReturnsResults(t *testing.T) { type fakePresence struct { available map[MediaType]map[int]bool + byTVDB map[MediaType]map[int]int + got []PresenceCandidate +} + +func (f *fakePresence) Lookup(_ context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) { + out := map[int]PresenceMatch{} + f.got = append(f.got, candidates...) + for _, candidate := range candidates { + if f.available != nil && f.available[mediaType][candidate.TMDBID] { + out[candidate.TMDBID] = PresenceMatch{Available: true, MatchedProvider: "tmdb"} + continue + } + if candidate.TVDBID != nil && f.byTVDB != nil { + if tmdbID, ok := f.byTVDB[mediaType][*candidate.TVDBID]; ok && tmdbID == candidate.TMDBID { + out[candidate.TMDBID] = PresenceMatch{Available: true, MatchedProvider: "tvdb"} + } + } + } + return out, nil } func (f *fakePresence) LookupTMDB(_ context.Context, mediaType MediaType, ids []int) (map[int]bool, error) { out := map[int]bool{} - if f.available == nil { - return out, nil - } for _, id := range ids { - if f.available[mediaType][id] { + matches, err := f.Lookup(context.Background(), mediaType, []PresenceCandidate{{TMDBID: id}}) + if err != nil { + return nil, err + } + if matches[id].Available { out[id] = true } } @@ -861,6 +965,8 @@ func (f *fakePresence) LookupTMDB(_ context.Context, mediaType MediaType, ids [] type fakeTMDBClient struct { page *tmdb.MediaPage externalIDs *tmdb.ExternalIDs + externalIDsByID map[int]*tmdb.ExternalIDs + externalIDCalls []int detail *tmdb.MediaDetail discoverPage *tmdb.MediaPage discoverErr error @@ -886,7 +992,11 @@ func (f *fakeTMDBClient) DiscoverPage(context.Context, string, tmdb.DiscoverPara return &tmdb.MediaPage{Results: []tmdb.MediaResult{}}, nil } -func (f *fakeTMDBClient) GetExternalIDs(context.Context, string, int) (*tmdb.ExternalIDs, error) { +func (f *fakeTMDBClient) GetExternalIDs(_ context.Context, _ string, id int) (*tmdb.ExternalIDs, error) { + f.externalIDCalls = append(f.externalIDCalls, id) + if f.externalIDsByID != nil { + return f.externalIDsByID[id], nil + } return f.externalIDs, nil }