[codex] feat(jellycompat): use catalog search provider (#236)
* feat(jellycompat): use catalog search provider * fix(jellycompat): route video search buckets to provider
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user