Search previously mixed audiobooks into movie/series results with no way to separate them beyond single-type filters. Backend: accept a new "video" group media scope (movies + series) anywhere a media_scope is valid, expanded centrally via MediaScopeItemTypes into the search item-type list, browse comma-list Type filter, and a type = ANY(...) condition in the query executor. Register a user-scoped search.media_scope setting (all|video|audiobook, default video). Frontend: Media / Audiobooks / All chips on the search results page that filter results and persist the choice as the user's default; the global search typeahead follows the same preference. An explicit URL ?type= always wins (with type=all as an unscoped sentinel), and the filter-bar dropdown gains a Movies & Series option. The API surface is additive, so Android/Apple clients are unaffected until they adopt the new scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
118 lines
3.4 KiB
Go
118 lines
3.4 KiB
Go
package catalog
|
|
|
|
import (
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestMediaScopeItemTypes pins the expansion of group scopes: "video" covers
|
|
// the video-side media_items types so search/browse can offer a
|
|
// "Movies & Series vs Audiobooks" split without enumerating types per caller.
|
|
func TestMediaScopeItemTypes(t *testing.T) {
|
|
cases := []struct {
|
|
scope string
|
|
want []string
|
|
}{
|
|
{"", nil},
|
|
{"movie", []string{"movie"}},
|
|
{"audiobook", []string{"audiobook"}},
|
|
{"video", []string{"movie", "series"}},
|
|
{" Video ", []string{"movie", "series"}},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := MediaScopeItemTypes(tc.scope); !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("MediaScopeItemTypes(%q) = %v, want %v", tc.scope, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMediaScopeMatchesItemType(t *testing.T) {
|
|
cases := []struct {
|
|
scope string
|
|
itemType string
|
|
want bool
|
|
}{
|
|
{"", "audiobook", true},
|
|
{"video", "movie", true},
|
|
{"video", "series", true},
|
|
{"video", "audiobook", false},
|
|
{"audiobook", "audiobook", true},
|
|
{"movie", "series", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := MediaScopeMatchesItemType(tc.scope, tc.itemType); got != tc.want {
|
|
t.Errorf("MediaScopeMatchesItemType(%q, %q) = %v, want %v", tc.scope, tc.itemType, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestParseCatalogRequest_VideoMediaScope asserts ?type=video parses into the
|
|
// query definition's media scope, and that catalogSearchAccess expands it to
|
|
// the video item types for the direct search path.
|
|
func TestParseCatalogRequest_VideoMediaScope(t *testing.T) {
|
|
req, err := ParseCatalogRequest(url.Values{
|
|
"source": {"query"},
|
|
"q": {"the rookie"},
|
|
"type": {"video"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ParseCatalogRequest: %v", err)
|
|
}
|
|
if req.Query.MediaScope != "video" {
|
|
t.Fatalf("expected media scope video, got %q", req.Query.MediaScope)
|
|
}
|
|
|
|
_, itemTypes, earlyEmpty := catalogSearchAccess(req, AccessFilter{})
|
|
if earlyEmpty {
|
|
t.Fatal("unexpected early empty")
|
|
}
|
|
if !reflect.DeepEqual(itemTypes, []string{"movie", "series"}) {
|
|
t.Fatalf("expected video scope to expand to movie+series, got %v", itemTypes)
|
|
}
|
|
}
|
|
|
|
// TestPreviewPage_VideoScopeUsesTypeAny asserts the preview/query-executor
|
|
// path renders a multi-type condition for the video group scope.
|
|
func TestPreviewPage_VideoScopeUsesTypeAny(t *testing.T) {
|
|
sql, args, err := (&QueryExecutor{}).buildPreviewPageSQL(
|
|
QueryDefinition{
|
|
MediaScope: "video",
|
|
Sort: QuerySort{Field: "title", Order: "asc"},
|
|
},
|
|
AccessFilter{},
|
|
20,
|
|
0,
|
|
true,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("buildPreviewPageSQL error: %v", err)
|
|
}
|
|
if !strings.Contains(sql, "mi.type = ANY(") {
|
|
t.Fatalf("expected mi.type = ANY(...) for video scope, got %s", sql)
|
|
}
|
|
found := false
|
|
for _, arg := range args {
|
|
if types, ok := arg.([]string); ok && reflect.DeepEqual(types, []string{"movie", "series"}) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected movie+series type arg, got %v", args)
|
|
}
|
|
}
|
|
|
|
// TestQueryDefinitionValidate_VideoScope asserts "video" passes definition
|
|
// validation alongside the single-type scopes.
|
|
func TestQueryDefinitionValidate_VideoScope(t *testing.T) {
|
|
def := QueryDefinition{MediaScope: "video"}
|
|
if err := def.Validate(); err != nil {
|
|
t.Fatalf("expected video media scope to validate, got %v", err)
|
|
}
|
|
bad := QueryDefinition{MediaScope: "podcast"}
|
|
if err := bad.Validate(); err == nil {
|
|
t.Fatal("expected invalid media scope to fail validation")
|
|
}
|
|
}
|