diff --git a/backend/appsettings/index_mapping.json b/backend/appsettings/index_mapping.json index 8014aef8..6b1d6936 100644 --- a/backend/appsettings/index_mapping.json +++ b/backend/appsettings/index_mapping.json @@ -53,7 +53,8 @@ "fields": { "keyword": { "type": "keyword", - "ignore_above": 256 + "ignore_above": 256, + "normalizer": "to_lower" } } }, @@ -209,7 +210,8 @@ "fields": { "keyword": { "type": "keyword", - "ignore_above": 256 + "ignore_above": 256, + "normalizer": "to_lower" } } }, diff --git a/backend/common/src/searching.py b/backend/common/src/searching.py index 44b75d0b..27b1e651 100644 --- a/backend/common/src/searching.py +++ b/backend/common/src/searching.py @@ -60,6 +60,8 @@ class SearchParser: def run(self): """collection, return path and query dict for es""" + if not self.query_words: + raise ValueError("empty search query") print(f"query words: {self.query_words}") query_type = self._find_map() self._run_words() @@ -76,17 +78,31 @@ class SearchParser: key_word_map = self._get_map() if ":" in first_word: - index_match, query_string = first_word.split(":") + index_match, query_string = first_word.split(":", 1) if index_match in key_word_map: self.query_map.update(key_word_map.get(index_match)) self.query_words[0] = query_string + if index_match != "video" and self._has_keyword("tag"): + raise ValueError("tag: is only supported in video mode") return index_match + if self._has_keyword("tag"): + self.query_map.update(key_word_map.get("video")) + return "video" + self.query_map.update(key_word_map.get("simple")) print(f"query_map: {self.query_map}") return "simple" + def _has_keyword(self, keyword): + """check if query contains keyword: bucket""" + for word in self.query_words: + if ":" in word and word.split(":", 1)[0] == keyword: + return True + + return False + @staticmethod def _get_map(): """return map to build on""" @@ -98,6 +114,7 @@ class SearchParser: "index": "ta_video", "channel": [], "active": [], + "tag": [], }, "channel": { "index": "ta_channel", @@ -121,7 +138,7 @@ class SearchParser: """append word by word""" for word in self.query_words: if ":" in word: - keyword, search_string = word.split(":") + keyword, search_string = word.split(":", 1) if keyword in self.query_map: self.append_to = keyword word = search_string @@ -182,13 +199,56 @@ class QueryBuilder: if self.query_type == "full": query = build_must_list() else: + must_list = build_must_list() + filter_list = [] + if self.query_type == "video": + if (tags := self.query_map.get("tag")) is not None: + filter_list.append(self._build_tag_filter(tags)) + elif self.query_map.get("tag") is not None: + raise ValueError("tag: is only supported in video mode") + + bool_query = {"must": must_list} + if filter_list: + bool_query["filter"] = filter_list + query = { "size": 30, - "query": {"bool": {"must": build_must_list()}}, + "query": {"bool": bool_query}, } return query + @staticmethod + def _build_tag_filter(tags): + """build OR tag filter list for tags.keyword""" + should_list = [] + for tag in tags: + should_list.append(QueryBuilder._build_tag_query(tag)) + + return {"bool": {"should": should_list, "minimum_should_match": 1}} + + @staticmethod + def _build_tag_query(tag): + """build per-tag query clause""" + if "?" in tag: + raise ValueError("unsupported wildcard for tag: only trailing * is supported") + + if "*" in tag: + if tag.count("*") != 1 or not tag.endswith("*") or tag.startswith("*"): + raise ValueError( + "unsupported wildcard for tag: only trailing * is supported" + ) + + prefix_value = tag[:-1] + if not prefix_value: + raise ValueError( + "unsupported wildcard for tag: only trailing * is supported" + ) + + return {"prefix": {"tags.keyword": {"value": prefix_value}}} + + return {"term": {"tags.keyword": {"value": tag}}} + def _get_fuzzy(self): """return fuziness valuee""" fuzzy_value = self.query_map.get("fuzzy", ["auto"])[0] diff --git a/backend/common/tests/test_src/test_searching_dsl.py b/backend/common/tests/test_src/test_searching_dsl.py new file mode 100644 index 00000000..1e787a68 --- /dev/null +++ b/backend/common/tests/test_src/test_searching_dsl.py @@ -0,0 +1,64 @@ +"""tests for search DSL parsing/building""" + +import pytest + +from common.src.searching import SearchParser + + +def test_tag_implies_video_mode(): + """tag: without explicit mode switches to video mode""" + path, query, query_type = SearchParser("tag:music").run() + assert query_type == "video" + assert path == "ta_video/_search" + assert query["query"]["bool"]["filter"] + + +def test_video_tag_or_filter_query_shape(): + """repeated tag: values OR together under query.bool.filter""" + _, query, query_type = SearchParser("video:lofi tag:music tag:dance").run() + assert query_type == "video" + + bool_query = query["query"]["bool"] + assert "filter" in bool_query + + tag_filter = bool_query["filter"][0]["bool"] + assert tag_filter["minimum_should_match"] == 1 + + should = tag_filter["should"] + assert len(should) == 2 + + values = {clause["term"]["tags.keyword"]["value"] for clause in should} + assert values == {"music", "dance"} + + +def test_video_tags_only_search(): + """tags-only search should work without a text term""" + _, query, query_type = SearchParser("video: tag:music tag:dance").run() + assert query_type == "video" + + bool_query = query["query"]["bool"] + assert bool_query["must"] == [] + assert bool_query["filter"] + + +def test_tag_prefix_wildcard_builds_prefix_query(): + """trailing * builds a prefix query""" + _, query, query_type = SearchParser("tag:music-*").run() + assert query_type == "video" + + should = query["query"]["bool"]["filter"][0]["bool"]["should"] + assert should == [{"prefix": {"tags.keyword": {"value": "music-"}}}] + + +def test_tag_invalid_wildcard_rejected(): + """leading wildcard is not supported""" + with pytest.raises( + ValueError, match=r"only trailing \* is supported" + ): + SearchParser("tag:*music").run() + + +def test_tag_in_non_video_mode_rejected(): + """tag: is a video-only concept""" + with pytest.raises(ValueError, match=r"only supported in video mode"): + SearchParser("channel:linux tag:music").run() diff --git a/backend/common/views.py b/backend/common/views.py index f7ba9973..8ab3e446 100644 --- a/backend/common/views.py +++ b/backend/common/views.py @@ -18,7 +18,7 @@ from common.src.searching import SearchForm from common.src.ta_redis import RedisArchivist from common.src.watched import WatchState from common.views_base import AdminOnly, ApiBaseView -from drf_spectacular.utils import OpenApiResponse, extend_schema +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework.response import Response from rest_framework.views import APIView from task.tasks import check_reindex @@ -156,16 +156,44 @@ class SearchView(ApiBaseView): """ @staticmethod + @extend_schema( + responses={ + 200: OpenApiResponse(description="Search results"), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + }, + parameters=[ + OpenApiParameter( + name="query", + description=( + "Search DSL string. Supports mode prefixes like " + "`video:`, `channel:`, `playlist:`, `full:`. " + "In `video` mode, `tag:` can be repeated to OR tags " + "(e.g. `video:lofi tag:music tag:dance`). " + "Trailing `*` is supported for prefix matches " + "(e.g. `tag:music-*`). Only trailing `*` is allowed." + ), + required=True, + type=str, + ), + ], + ) def get(request): """handle get request search through all indexes""" search_query = request.GET.get("query", None) - if search_query is None: + if search_query is None or not search_query.strip(): return Response( {"message": "no search query specified"}, status=400 ) - search_results = SearchForm().multi_search(search_query) + try: + search_results = SearchForm().multi_search(search_query) + except ValueError as exc: + error = ErrorResponseSerializer({"error": str(exc)}) + return Response(error.data, status=400) + return Response(search_results) diff --git a/frontend/src/api/loader/loadSearch.ts b/frontend/src/api/loader/loadSearch.ts index c373a4e3..d0e9a7f8 100644 --- a/frontend/src/api/loader/loadSearch.ts +++ b/frontend/src/api/loader/loadSearch.ts @@ -16,7 +16,7 @@ export type SearchResultsType = { }; const loadSearch = async (query: string) => { - return APIClient(`/api/search/?query=${query}`); + return APIClient(`/api/search/?query=${encodeURIComponent(query)}`); }; export default loadSearch; diff --git a/frontend/src/components/SearchExampleQueries.tsx b/frontend/src/components/SearchExampleQueries.tsx index a4822b61..b2cb497a 100644 --- a/frontend/src/components/SearchExampleQueries.tsx +++ b/frontend/src/components/SearchExampleQueries.tsx @@ -5,11 +5,21 @@ const SearchExampleQueries = () => {

Example queries