diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 9d58b01d..70000589 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -2185,6 +2185,17 @@ func main() { compatDeps.WatchCompletionObserver = deps.WatchCompletionObserver compatDeps.SettingsRepo = settingsRepo compatDeps.PersonRepo = personRepo + compatSearchService := catalog.NewCatalogSearchService( + appCtx, + settingsRepo, + itemRepo, + catalog.NewSearchIndexEventRepository(deps.DB), + deps.CatalogSearchVectorizer, + ) + if compatSearchService != nil { + compatSearchService.StartCoverageRefresh(appCtx) + compatDeps.CatalogSearchProvider = compatSearchService.Provider() + } if deps.S3Public != nil { compatDeps.PosterPresigner = deps.S3Public diff --git a/internal/api/router.go b/internal/api/router.go index 6fc9621a..498521f8 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -449,14 +449,10 @@ func NewRouter(deps Dependencies) chi.Router { ebookAnnotationStore = handlers.NewPGEbookReaderAnnotationStore(deps.DB) browseRepo := catalog.NewBrowseRepository(deps.DB) itemRepo = catalog.NewItemRepository(deps.DB) - catalogSearchSettings, err := catalog.LoadCatalogSearchSettings(context.Background(), settingsRepo) - if err != nil { - slog.Warn("catalog search: failed to load settings; using postgres", "err", err) - catalogSearchSettings = catalog.DefaultCatalogSearchSettings() - } searchIndexEvents := catalog.NewSearchIndexEventRepository(deps.DB) - catalogSearchService = catalog.NewCatalogSearchServiceFromSettings( - catalogSearchSettings, + catalogSearchService = catalog.NewCatalogSearchService( + context.Background(), + settingsRepo, itemRepo, searchIndexEvents, deps.CatalogSearchVectorizer, diff --git a/internal/jellycompat/batch_progress_test.go b/internal/jellycompat/batch_progress_test.go index 44f7696c..1308eebc 100644 --- a/internal/jellycompat/batch_progress_test.go +++ b/internal/jellycompat/batch_progress_test.go @@ -110,7 +110,7 @@ func (s *stubContentService) BrowseItems(context.Context, *Session, url.Values) panic("unused") } -func (s *stubContentService) SearchItems(context.Context, *Session, string, []string, int, int, *int) (*upstreamBrowseResponse, error) { +func (s *stubContentService) SearchItems(context.Context, *Session, SearchItemsOptions) (*upstreamBrowseResponse, error) { panic("unused") } diff --git a/internal/jellycompat/content_direct.go b/internal/jellycompat/content_direct.go index de834d68..4e2e3614 100644 --- a/internal/jellycompat/content_direct.go +++ b/internal/jellycompat/content_direct.go @@ -195,6 +195,7 @@ type episodeListSource interface { type directContentService struct { browseRepo browseSource itemRepo itemAccessSource + searchProvider catalog.CatalogSearchProvider seasonRepo seasonListSource episodeRepo episodeListSource detailSvc *catalog.DetailService @@ -214,16 +215,18 @@ func newDirectContentService( folderRepo folderListSource, storeProvider userstore.UserStoreProvider, accessFilter AccessFilterResolver, + searchProvider catalog.CatalogSearchProvider, ) *directContentService { return &directContentService{ - browseRepo: browseRepo, - itemRepo: itemRepo, - seasonRepo: seasonRepo, - episodeRepo: episodeRepo, - detailSvc: detailSvc, - folderRepo: folderRepo, - storeProvider: storeProvider, - accessFilter: accessFilter, + browseRepo: browseRepo, + itemRepo: itemRepo, + searchProvider: searchProvider, + seasonRepo: seasonRepo, + episodeRepo: episodeRepo, + detailSvc: detailSvc, + folderRepo: folderRepo, + storeProvider: storeProvider, + accessFilter: accessFilter, } } @@ -456,12 +459,37 @@ func parseContentIDParam(raw string) []string { return ids } -func (s *directContentService) SearchItems(ctx context.Context, session *Session, query string, itemTypes []string, limit, offset int, libraryID *int) (*upstreamBrowseResponse, error) { - filter := applyCompatPresentationLibrary(s.resolveFilter(ctx, session), libraryID) +func (s *directContentService) SearchItems(ctx context.Context, session *Session, opts SearchItemsOptions) (*upstreamBrowseResponse, error) { + filter := applyCompatPresentationLibrary(s.resolveFilter(ctx, session), opts.LibraryID) + itemTypes := compatScopedSearchTypes(opts.ItemTypes) - items, total, err := s.itemRepo.Search(ctx, query, compatScopedSearchTypes(itemTypes), limit, offset, filter) - if err != nil { - return nil, fmt.Errorf("search items: %w", err) + var items []*models.MediaItem + var total int + var hasMore bool + if s.searchProvider != nil { + result, err := s.searchProvider.Search(ctx, catalog.CatalogSearchRequest{ + Query: opts.Query, + ItemTypes: itemTypes, + Limit: opts.Limit, + Offset: opts.Offset, + Access: filter, + SkipTotal: opts.SkipTotal, + }) + if err != nil { + return nil, fmt.Errorf("search items: %w", err) + } + if result != nil { + items = result.Items + total = result.Total + hasMore = result.HasMore + } + } else { + var err error + items, total, err = s.itemRepo.Search(ctx, opts.Query, itemTypes, opts.Limit, opts.Offset, filter) + if err != nil { + return nil, fmt.Errorf("search items: %w", err) + } + hasMore = opts.Offset+len(items) < total } listItems := make([]upstreamListItem, 0, len(items)) @@ -478,7 +506,7 @@ func (s *directContentService) SearchItems(ctx context.Context, session *Session return &upstreamBrowseResponse{ Total: total, - HasMore: offset+len(listItems) < total, + HasMore: hasMore, Items: listItems, }, nil } diff --git a/internal/jellycompat/content_direct_test.go b/internal/jellycompat/content_direct_test.go index 8c3f85c8..05cd6cf1 100644 --- a/internal/jellycompat/content_direct_test.go +++ b/internal/jellycompat/content_direct_test.go @@ -460,6 +460,44 @@ func (s *stubBrowseSource) ListGenres(_ context.Context, _ catalog.BrowseFilters return nil, nil } +type recordingCatalogSearchProvider struct { + requests []catalog.CatalogSearchRequest + result *catalog.CatalogSearchResult +} + +func (p *recordingCatalogSearchProvider) Search(_ context.Context, req catalog.CatalogSearchRequest) (*catalog.CatalogSearchResult, error) { + p.requests = append(p.requests, req) + if p.result != nil { + return p.result, nil + } + return &catalog.CatalogSearchResult{}, nil +} + +type recordingItemAccessSource struct { + searchQueries []string + searchTypes [][]string + searchLimits []int + searchOffsets []int + items []*models.MediaItem + total int +} + +func (s *recordingItemAccessSource) EnsureAccessible(context.Context, string, catalog.AccessFilter) error { + return nil +} + +func (s *recordingItemAccessSource) Search(_ context.Context, query string, itemTypes []string, limit, offset int, _ catalog.AccessFilter) ([]*models.MediaItem, int, error) { + s.searchQueries = append(s.searchQueries, query) + s.searchTypes = append(s.searchTypes, append([]string(nil), itemTypes...)) + s.searchLimits = append(s.searchLimits, limit) + s.searchOffsets = append(s.searchOffsets, offset) + return append([]*models.MediaItem(nil), s.items...), s.total, nil +} + +func (s *recordingItemAccessSource) GetByIDs(context.Context, []string) ([]*models.MediaItem, error) { + return nil, nil +} + // newDirectContentServiceForTest builds a directContentService with stubbed // catalog dependencies. Useful for behavioral tests that don't need real // Postgres state. @@ -470,6 +508,97 @@ func newDirectContentServiceForTest(browse browseSource, provider userstore.User } } +func TestSearchItemsUsesCatalogSearchProviderWithCompatScope(t *testing.T) { + libraryID := 7 + provider := &recordingCatalogSearchProvider{ + result: &catalog.CatalogSearchResult{ + Items: []*models.MediaItem{{ + ContentID: "movie-1", + Type: "movie", + Title: "Dune", + }}, + Total: 12, + HasMore: true, + }, + } + svc := &directContentService{ + searchProvider: provider, + accessFilter: func(context.Context, int, string) catalog.AccessFilter { + return catalog.AccessFilter{ + AllowedLibraryIDs: []int{1, 2}, + ExcludedMediaTypes: []string{"ebook"}, + MaxContentRating: "PG-13", + } + }, + } + + result, err := svc.SearchItems(context.Background(), &Session{ + StreamAppUserID: 22, + ProfileID: "profile-1", + }, SearchItemsOptions{ + Query: "dune", + Limit: 5, + Offset: 10, + LibraryID: &libraryID, + SkipTotal: true, + }) + if err != nil { + t.Fatalf("SearchItems error: %v", err) + } + if len(provider.requests) != 1 { + t.Fatalf("provider requests = %d, want 1", len(provider.requests)) + } + req := provider.requests[0] + if req.Query != "dune" || req.Limit != 5 || req.Offset != 10 || !req.SkipTotal { + t.Fatalf("provider request shape = %#v", req) + } + if want := []string{"movie", "series", "episode"}; !slices.Equal(req.ItemTypes, want) { + t.Fatalf("ItemTypes = %#v, want %#v", req.ItemTypes, want) + } + if req.Access.PresentationLibraryID == nil || *req.Access.PresentationLibraryID != libraryID { + t.Fatalf("PresentationLibraryID = %#v, want %d", req.Access.PresentationLibraryID, libraryID) + } + for _, mediaType := range []string{"ebook", "audiobook", "podcast"} { + if !slices.Contains(req.Access.ExcludedMediaTypes, mediaType) { + t.Fatalf("ExcludedMediaTypes = %#v, missing %q", req.Access.ExcludedMediaTypes, mediaType) + } + } + if result.Total != 12 || !result.HasMore || len(result.Items) != 1 || result.Items[0].Title != "Dune" { + t.Fatalf("result = %#v", result) + } +} + +func TestSearchItemsFallsBackToItemRepoWhenProviderMissing(t *testing.T) { + itemRepo := &recordingItemAccessSource{ + items: []*models.MediaItem{{ + ContentID: "movie-1", + Type: "movie", + Title: "Fallback", + }}, + total: 3, + } + svc := &directContentService{itemRepo: itemRepo} + + result, err := svc.SearchItems(context.Background(), &Session{}, SearchItemsOptions{ + Query: "fallback", + ItemTypes: []string{"MusicAlbum"}, + Limit: 2, + Offset: 1, + }) + if err != nil { + t.Fatalf("SearchItems error: %v", err) + } + if len(itemRepo.searchQueries) != 1 || itemRepo.searchQueries[0] != "fallback" { + t.Fatalf("search queries = %#v", itemRepo.searchQueries) + } + if want := []string{compatNoMatchType}; !slices.Equal(itemRepo.searchTypes[0], want) { + t.Fatalf("search types = %#v, want %#v", itemRepo.searchTypes[0], want) + } + if result.Total != 3 || !result.HasMore || len(result.Items) != 1 || result.Items[0].Title != "Fallback" { + t.Fatalf("result = %#v", result) + } +} + // TestBrowseItems_DoesNotFetchProgressWhenNoPlayedFilter verifies that // BrowseItems does NOT call ListProgressByMediaItems on the user store when // the is_played filter is empty. The handler-level resolveUserStateForContentIDs diff --git a/internal/jellycompat/content_service.go b/internal/jellycompat/content_service.go index 1537e100..76427201 100644 --- a/internal/jellycompat/content_service.go +++ b/internal/jellycompat/content_service.go @@ -9,7 +9,7 @@ import ( type ContentService interface { ListUserLibraries(ctx context.Context, session *Session) ([]upstreamUserLibrary, error) BrowseItems(ctx context.Context, session *Session, params url.Values) (*upstreamBrowseResponse, error) - SearchItems(ctx context.Context, session *Session, query string, itemTypes []string, limit, offset int, libraryID *int) (*upstreamBrowseResponse, error) + SearchItems(ctx context.Context, session *Session, opts SearchItemsOptions) (*upstreamBrowseResponse, error) GetItemDetail(ctx context.Context, session *Session, contentID string, libraryID *int) (*upstreamItemDetail, error) ListSeasons(ctx context.Context, session *Session, seriesID string, libraryID *int) ([]upstreamSeason, error) GetSeason(ctx context.Context, session *Session, seriesID string, seasonNumber int, libraryID *int) (*upstreamSeason, error) @@ -18,6 +18,15 @@ type ContentService interface { ListItemFilters(ctx context.Context, session *Session, params url.Values) (*upstreamItemFiltersResponse, error) } +type SearchItemsOptions struct { + Query string + ItemTypes []string + Limit int + Offset int + LibraryID *int + SkipTotal bool +} + // UserDataService provides favorites/progress/watched operations. type UserDataService interface { ListFavorites(ctx context.Context, session *Session, limit, offset int) ([]upstreamListItem, error) diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index 841a047b..4f51619a 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -272,6 +272,17 @@ func itemTypesContain(itemTypes []string, target string) bool { return false } +func searchItemTypesForQuery(query itemsQuery) []string { + itemTypes := append([]string(nil), query.itemTypes...) + if query.mediaTypesExplicit && !query.mediaTypesSet["video"] { + return []string{compatNoMatchType} + } + if query.hasItemTypeFilter && len(itemTypes) == 0 { + return []string{compatNoMatchType} + } + return itemTypes +} + // HandleItem serves GET /Items/{id}. func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) { session := SessionFromContext(r.Context()) @@ -1605,7 +1616,10 @@ func (h *ItemsHandler) HandleSearchHints(w http.ResponseWriter, r *http.Request) } limit := parsePositiveInt(q.Get("Limit"), 20) - result, err := h.content.SearchItems(r.Context(), session, query, nil, limit, 0, nil) + result, err := h.content.SearchItems(r.Context(), session, SearchItemsOptions{ + Query: query, + Limit: limit, + }) if err != nil { writeCompatUpstreamError(w, err) return @@ -1864,7 +1878,14 @@ func (h *ItemsHandler) handleFavoriteItems(w http.ResponseWriter, r *http.Reques } func (h *ItemsHandler) handleSearchItems(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) { - result, err := h.content.SearchItems(r.Context(), session, query.searchTerm, query.itemTypes, query.limit, query.startIndex, libraryIDPtr(query.parentLibraryID)) + result, err := h.content.SearchItems(r.Context(), session, SearchItemsOptions{ + Query: query.searchTerm, + ItemTypes: searchItemTypesForQuery(query), + Limit: query.limit, + Offset: query.startIndex, + LibraryID: libraryIDPtr(query.parentLibraryID), + SkipTotal: !query.enableTotalRecordCount, + }) if err != nil { writeCompatUpstreamError(w, err) return diff --git a/internal/jellycompat/handlers_items_test.go b/internal/jellycompat/handlers_items_test.go index 7826ab85..812293cb 100644 --- a/internal/jellycompat/handlers_items_test.go +++ b/internal/jellycompat/handlers_items_test.go @@ -50,7 +50,7 @@ func (s *countingContentService) BrowseItems(context.Context, *Session, url.Valu panic("unused") } -func (s *countingContentService) SearchItems(context.Context, *Session, string, []string, int, int, *int) (*upstreamBrowseResponse, error) { +func (s *countingContentService) SearchItems(context.Context, *Session, SearchItemsOptions) (*upstreamBrowseResponse, error) { panic("unused") } @@ -81,6 +81,20 @@ func (s *countingContentService) ListItemFilters(context.Context, *Session, url. panic("unused") } +type recordingSearchContentService struct { + countingContentService + options []SearchItemsOptions + result *upstreamBrowseResponse +} + +func (s *recordingSearchContentService) SearchItems(_ context.Context, _ *Session, opts SearchItemsOptions) (*upstreamBrowseResponse, error) { + s.options = append(s.options, opts) + if s.result != nil { + return s.result, nil + } + return &upstreamBrowseResponse{Items: []upstreamListItem{}}, nil +} + func TestHandleItems_SeriesParentSeasonFilterReturnsPagedSeasons(t *testing.T) { codec := NewResourceIDCodec() seriesContentID := "series-1" @@ -140,6 +154,133 @@ func TestHandleItems_SeriesParentSeasonFilterReturnsPagedSeasons(t *testing.T) { } } +func TestHandleItemsSearchPropagatesEnableTotalRecordCount(t *testing.T) { + for _, tc := range []struct { + name string + querySuffix string + wantSkipTotal bool + }{ + {name: "default includes total", wantSkipTotal: false}, + {name: "disabled skips total", querySuffix: "&EnableTotalRecordCount=false", wantSkipTotal: true}, + } { + t.Run(tc.name, func(t *testing.T) { + codec := NewResourceIDCodec() + contentSvc := &recordingSearchContentService{} + h := &ItemsHandler{ + content: contentSvc, + userData: &mockUserDataService{}, + codec: codec, + mapper: newMapper(codec, &config.Config{}), + images: NewImageCache(time.Hour, time.Now), + } + + req := httptest.NewRequest("GET", "/Users/test/Items?SearchTerm=dune&Limit=5&StartIndex=2"+tc.querySuffix, nil) + req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{ + StreamAppUserID: 1, + ProfileID: "profile-1", + })) + + rec := httptest.NewRecorder() + h.HandleItems(rec, req) + + if rec.Code != 200 { + t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String()) + } + if len(contentSvc.options) != 1 { + t.Fatalf("SearchItems calls = %d, want 1", len(contentSvc.options)) + } + opts := contentSvc.options[0] + if opts.Query != "dune" || opts.Limit != 5 || opts.Offset != 2 { + t.Fatalf("SearchItems options = %#v", opts) + } + if opts.SkipTotal != tc.wantSkipTotal { + t.Fatalf("SkipTotal = %v, want %v", opts.SkipTotal, tc.wantSkipTotal) + } + }) + } +} + +func TestHandleItemsSearchMediaTypesVideoExcludeMovieEpisodeSearchesSeries(t *testing.T) { + codec := NewResourceIDCodec() + contentSvc := &recordingSearchContentService{} + h := &ItemsHandler{ + content: contentSvc, + userData: &mockUserDataService{}, + codec: codec, + mapper: newMapper(codec, &config.Config{}), + images: NewImageCache(time.Hour, time.Now), + } + + req := httptest.NewRequest("GET", "/Items?SearchTerm=sponge+bob&Limit=100"+ + "&ExcludeItemTypes=Movie&ExcludeItemTypes=Episode&ExcludeItemTypes=TvChannel"+ + "&MediaTypes=Video&EnableTotalRecordCount=false", nil) + req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{ + StreamAppUserID: 1, + ProfileID: "profile-1", + })) + + rec := httptest.NewRecorder() + h.HandleItems(rec, req) + + if rec.Code != 200 { + t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String()) + } + if len(contentSvc.options) != 1 { + t.Fatalf("SearchItems calls = %d, want 1", len(contentSvc.options)) + } + opts := contentSvc.options[0] + if opts.Query != "sponge bob" || opts.Limit != 100 || !opts.SkipTotal { + t.Fatalf("SearchItems options = %#v", opts) + } + if len(opts.ItemTypes) != 1 || opts.ItemTypes[0] != "series" { + t.Fatalf("ItemTypes = %v, want [series]", opts.ItemTypes) + } + + var result queryResultDTO + if err := json.NewDecoder(rec.Body).Decode(&result); err != nil { + t.Fatalf("decode response: %v", err) + } + if result.TotalRecordCount != 0 || len(result.Items) != 0 { + t.Fatalf("result = total %d items %d, want empty", result.TotalRecordCount, len(result.Items)) + } +} + +func TestHandleItemsSearchSeriesScopeReachesProvider(t *testing.T) { + codec := NewResourceIDCodec() + contentSvc := &recordingSearchContentService{} + h := &ItemsHandler{ + content: contentSvc, + userData: &mockUserDataService{}, + codec: codec, + mapper: newMapper(codec, &config.Config{}), + images: NewImageCache(time.Hour, time.Now), + } + + req := httptest.NewRequest("GET", "/Items?SearchTerm=spongebob&Limit=100"+ + "&IncludeItemTypes=Series&EnableTotalRecordCount=false", nil) + req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{ + StreamAppUserID: 1, + ProfileID: "profile-1", + })) + + rec := httptest.NewRecorder() + h.HandleItems(rec, req) + + if rec.Code != 200 { + t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String()) + } + if len(contentSvc.options) != 1 { + t.Fatalf("SearchItems calls = %d, want 1", len(contentSvc.options)) + } + opts := contentSvc.options[0] + if opts.Query != "spongebob" || opts.Limit != 100 || !opts.SkipTotal { + t.Fatalf("SearchItems options = %#v", opts) + } + if len(opts.ItemTypes) != 1 || opts.ItemTypes[0] != "series" { + t.Fatalf("ItemTypes = %v, want [series]", opts.ItemTypes) + } +} + // TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags verifies // that episode detail responses fetch parent series image metadata even when // image URLs are already cached. Cached URLs are not enough to build stable diff --git a/internal/jellycompat/handlers_persons.go b/internal/jellycompat/handlers_persons.go index 2a08f4d4..b21d5542 100644 --- a/internal/jellycompat/handlers_persons.go +++ b/internal/jellycompat/handlers_persons.go @@ -147,7 +147,12 @@ func (h *PersonsHandler) shouldSuppressSearchPeople(ctx context.Context, session return false } - media, err := h.content.SearchItems(ctx, session, raw, []string{"movie", "series"}, 5, 0, nil) + media, err := h.content.SearchItems(ctx, session, SearchItemsOptions{ + Query: raw, + ItemTypes: []string{"movie", "series"}, + Limit: 5, + SkipTotal: true, + }) if err != nil || media == nil || len(media.Items) == 0 { return false } diff --git a/internal/jellycompat/query.go b/internal/jellycompat/query.go index 50e2671d..c301ff82 100644 --- a/internal/jellycompat/query.go +++ b/internal/jellycompat/query.go @@ -28,7 +28,7 @@ type itemsQuery struct { genreName string isFavorite bool isResumable bool - hasItemTypeFilter bool // true when IncludeItemTypes was present in the request + hasItemTypeFilter bool // true when IncludeItemTypes or ExcludeItemTypes was present in the request wantsBoxSets bool // true when IncludeItemTypes contains BoxSet wantsViews bool // true when IncludeItemTypes contains CollectionFolder sortExplicit bool // true when SortBy was present in the request @@ -110,8 +110,9 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery { } rawItemTypes := q.Values("IncludeItemTypes") - result.hasItemTypeFilter = len(rawItemTypes) > 0 && strings.TrimSpace(strings.Join(rawItemTypes, "")) != "" - result.itemTypes = mapIncludeItemTypes(rawItemTypes) + rawExcludedItemTypes := q.Values("ExcludeItemTypes") + result.hasItemTypeFilter = hasNonEmptyValues(rawItemTypes) || hasNonEmptyValues(rawExcludedItemTypes) + result.itemTypes = effectiveItemTypes(rawItemTypes, rawExcludedItemTypes) result.wantsBoxSets = includeItemTypesContain(rawItemTypes, "boxset") result.wantsViews = includeItemTypesContain(rawItemTypes, "collectionfolder") result.sortExplicit = strings.TrimSpace(q.Get("SortBy")) != "" @@ -342,6 +343,44 @@ func mapIncludeItemTypes(rawValues []string) []string { return result } +func effectiveItemTypes(rawIncluded, rawExcluded []string) []string { + included := mapIncludeItemTypes(rawIncluded) + excluded := mapIncludeItemTypes(rawExcluded) + if len(excluded) == 0 { + return included + } + + base := included + if len(base) == 0 && !hasNonEmptyValues(rawIncluded) { + base = compatVideoTypeList + } + if len(base) == 0 { + return nil + } + + excludedSet := make(map[string]struct{}, len(excluded)) + for _, itemType := range excluded { + excludedSet[itemType] = struct{}{} + } + result := make([]string, 0, len(base)) + for _, itemType := range base { + if _, skip := excludedSet[itemType]; skip { + continue + } + result = append(result, itemType) + } + return result +} + +func hasNonEmptyValues(values []string) bool { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return true + } + } + return false +} + // includeItemTypesContain reports whether a raw IncludeItemTypes value list // contains the given (lowercase) type, before mapIncludeItemTypes drops // entries it cannot map to catalog types (e.g. BoxSet). diff --git a/internal/jellycompat/query_test.go b/internal/jellycompat/query_test.go index 780386c3..425b344b 100644 --- a/internal/jellycompat/query_test.go +++ b/internal/jellycompat/query_test.go @@ -40,6 +40,30 @@ func TestBuildBrowseParamsPropagatesEnableTotalRecordCount(t *testing.T) { } } +func TestParseItemsQueryAppliesExcludeItemTypesToDefaultVideoScope(t *testing.T) { + req := httptest.NewRequest("GET", "/Items?SearchTerm=sponge+bob"+ + "&ExcludeItemTypes=Movie&ExcludeItemTypes=Episode&ExcludeItemTypes=TvChannel", nil) + + query := parseItemsQuery(req, NewResourceIDCodec()) + + if !query.hasItemTypeFilter { + t.Fatal("expected ExcludeItemTypes to count as an item type filter") + } + if len(query.itemTypes) != 1 || query.itemTypes[0] != "series" { + t.Fatalf("itemTypes = %v, want [series]", query.itemTypes) + } +} + +func TestParseItemsQuerySubtractsExcludeItemTypesFromIncludeItemTypes(t *testing.T) { + req := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Movie,Series&ExcludeItemTypes=Movie", nil) + + query := parseItemsQuery(req, NewResourceIDCodec()) + + if len(query.itemTypes) != 1 || query.itemTypes[0] != "series" { + t.Fatalf("itemTypes = %v, want [series]", query.itemTypes) + } +} + func TestMapSortByReleaseDate(t *testing.T) { tests := []string{ "PremiereDate", diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index bfecc3ac..d2bb80f2 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -310,6 +310,7 @@ func withDefaults(deps Dependencies) Dependencies { deps.FolderRepo, deps.UserStoreProvider, deps.AccessFilterFn, + deps.CatalogSearchProvider, ) if deps.PosterPresigner != nil { svc.posterPresigner = deps.PosterPresigner diff --git a/internal/jellycompat/server.go b/internal/jellycompat/server.go index 756cb45e..1c88464d 100644 --- a/internal/jellycompat/server.go +++ b/internal/jellycompat/server.go @@ -62,13 +62,14 @@ type Dependencies struct { ScanQueue scantrigger.Queuer // Catalog repos (for ContentService construction) - BrowseRepo *catalog.BrowseRepository - ItemRepo *catalog.ItemRepository - SeasonRepo *catalog.SeasonRepository - EpisodeRepo *catalog.EpisodeRepository - ProviderIDRepo *catalog.ProviderIDRepository - DetailSvc *catalog.DetailService - FolderRepo *catalog.FolderRepository + BrowseRepo *catalog.BrowseRepository + ItemRepo *catalog.ItemRepository + SeasonRepo *catalog.SeasonRepository + EpisodeRepo *catalog.EpisodeRepository + ProviderIDRepo *catalog.ProviderIDRepository + DetailSvc *catalog.DetailService + FolderRepo *catalog.FolderRepository + CatalogSearchProvider catalog.CatalogSearchProvider // Person repository PersonRepo *catalog.PersonRepository