Add tagsearch backend and frontend
This commit is contained in:
parent
4e14f19bee
commit
48592ea626
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export type SearchResultsType = {
|
|||
};
|
||||
|
||||
const loadSearch = async (query: string) => {
|
||||
return APIClient<SearchResultsType>(`/api/search/?query=${query}`);
|
||||
return APIClient<SearchResultsType>(`/api/search/?query=${encodeURIComponent(query)}`);
|
||||
};
|
||||
|
||||
export default loadSearch;
|
||||
|
|
|
|||
|
|
@ -5,11 +5,21 @@ const SearchExampleQueries = () => {
|
|||
<h2>Example queries</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<span className="value">music video</span> — basic search
|
||||
<span className="value">music video</span> - basic search
|
||||
</li>
|
||||
<li>
|
||||
<span>tag:</span>
|
||||
<span className="value">music</span>
|
||||
<span> tag:</span>
|
||||
<span className="value">dance</span> - match any tag
|
||||
</li>
|
||||
<li>
|
||||
<span>tag:</span>
|
||||
<span className="value">music-*</span> - tag prefix match
|
||||
</li>
|
||||
<li>
|
||||
<span>video: active:</span>
|
||||
<span className="value">no</span> — all videos deleted from YouTube
|
||||
<span className="value">no</span> - all videos deleted from YouTube
|
||||
</li>
|
||||
<li>
|
||||
<span>video:</span>
|
||||
|
|
@ -51,10 +61,14 @@ const SearchExampleQueries = () => {
|
|||
titles
|
||||
</li>
|
||||
<li>
|
||||
<span>video:</span> — search in video titles, tags and category field
|
||||
<span>video:</span> - search in video titles, tags and category field
|
||||
<ul>
|
||||
<li>
|
||||
<span>channel:</span> — channel name
|
||||
<span>tag:</span> - filter videos by tag (repeatable = OR, trailing <code>*</code>{' '}
|
||||
for prefix)
|
||||
</li>
|
||||
<li>
|
||||
<span>channel:</span> - channel name
|
||||
</li>
|
||||
<li>
|
||||
<span>active:</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||
import ChannelOverview from '../components/ChannelOverview';
|
||||
import { useEffect, useState } from 'react';
|
||||
import loadChannelById, { ChannelResponseType } from '../api/loader/loadChannelById';
|
||||
|
|
@ -238,9 +238,13 @@ const ChannelAbout = () => {
|
|||
<div className="video-tag-box">
|
||||
{channel.channel_tags.map(tag => {
|
||||
return (
|
||||
<span key={tag} className="video-tag">
|
||||
<Link
|
||||
key={tag}
|
||||
className="video-tag"
|
||||
to={`${Routes.Search}?query=${encodeURIComponent(`tag:${tag}`)}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const Search = () => {
|
|||
const { userConfig } = useUserConfigStore();
|
||||
const [searchParams] = useSearchParams();
|
||||
const videoId = searchParams.get('videoId');
|
||||
const queryParam = searchParams.get('query');
|
||||
|
||||
const viewVideos = userConfig.view_style_home;
|
||||
const viewChannels = userConfig.view_style_channel;
|
||||
|
|
@ -72,6 +73,12 @@ const Search = () => {
|
|||
setRefresh(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (queryParam !== null) {
|
||||
setSearchTerm(queryParam);
|
||||
}
|
||||
}, [queryParam]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
|
|
|
|||
|
|
@ -484,9 +484,13 @@ const Video = () => {
|
|||
<div className="video-tag-box">
|
||||
{video.tags.map(tag => {
|
||||
return (
|
||||
<span key={tag} className="video-tag">
|
||||
<Link
|
||||
key={tag}
|
||||
className="video-tag"
|
||||
to={`${Routes.Search}?query=${encodeURIComponent(`tag:${tag}`)}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue