fix(requests): hydrate external ids before availability

This commit is contained in:
Silo Server Migration
2026-05-24 22:57:44 -04:00
parent 703797e0bb
commit e470760ab4
3 changed files with 201 additions and 20 deletions
+1 -1
View File
@@ -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 {
+85 -14
View File
@@ -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) {
+115 -5
View File
@@ -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
}