Add tagsearch backend and frontend

This commit is contained in:
Jonas Kamsker 2025-12-17 17:00:52 +01:00
parent 4e14f19bee
commit 48592ea626
9 changed files with 201 additions and 18 deletions

View File

@ -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"
}
}
},

View File

@ -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]

View File

@ -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()

View File

@ -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)

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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);

View File

@ -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>