mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
Search Channels by name in the Other search tab
The Other tab only listed Channels with the selected Tags, so typing a Channel's name found nothing. Channels now match the search text by name, and the Tags when there are both. The list and its estimate share one query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4bd86d3eb1
commit
52cf7a9520
9 changed files with 88 additions and 38 deletions
|
|
@ -2118,8 +2118,8 @@ export async function searchEstimateOthers(tagNames, searchStr) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function searchChannels(tagNames) {
|
||||
const body = {tag_names: tagNames};
|
||||
export async function searchChannels(tagNames, searchStr) {
|
||||
const body = {tag_names: tagNames, search_str: searchStr};
|
||||
const response = await apiPost(`${API_URI}/videos/channels/search`, body);
|
||||
if (response.ok) {
|
||||
const content = await response.json();
|
||||
|
|
|
|||
|
|
@ -601,7 +601,7 @@ export function OtherSearchView({loading}) {
|
|||
const [activeValues, setActiveValues] = React.useState(['channels', 'repos']);
|
||||
const activeTags = searchParams.getAll('tag');
|
||||
const searchStr = searchParams.get('q') || '';
|
||||
const {channels, loading: channelsLoading} = useSearchChannels(activeTags);
|
||||
const {channels, loading: channelsLoading} = useSearchChannels(activeTags, searchStr);
|
||||
const {repos} = useSearchRepos(searchStr, activeTags);
|
||||
|
||||
if (loading || channelsLoading || channels === null) {
|
||||
|
|
|
|||
|
|
@ -2107,7 +2107,8 @@ export const useCalcQuery = () => {
|
|||
return [calc, setCalc]
|
||||
}
|
||||
|
||||
export const useSearchChannels = (defaultTagNames) => {
|
||||
// Channels whose name matches `searchStr` and/or which have any of the tags.
|
||||
export const useSearchChannels = (defaultTagNames, searchStr) => {
|
||||
const [tagNames, setTagNames] = useState(defaultTagNames || []);
|
||||
// null = pending, undefined = fetch failed, [] = no channels. A search starts on mount, so the
|
||||
// first render is already loading; consumers must never see [] for a search that has not run.
|
||||
|
|
@ -2121,7 +2122,7 @@ export const useSearchChannels = (defaultTagNames) => {
|
|||
setChannels(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const {channels: newChannels} = await searchChannels(tagNames);
|
||||
const {channels: newChannels} = await searchChannels(tagNames, searchStr);
|
||||
if (gen === requestGen.current) {
|
||||
setChannels(newChannels || []);
|
||||
}
|
||||
|
|
@ -2147,7 +2148,7 @@ export const useSearchChannels = (defaultTagNames) => {
|
|||
|
||||
useEffect(() => {
|
||||
localSearchChannels();
|
||||
}, [tagNames]);
|
||||
}, [tagNames, searchStr]);
|
||||
|
||||
return {
|
||||
tagNames,
|
||||
|
|
|
|||
|
|
@ -475,7 +475,7 @@ describe('useSearchChannels', () => {
|
|||
const {result} = renderHook(() => useSearchChannels(['a']));
|
||||
await act(async () => {
|
||||
});
|
||||
expect(searchChannels).toHaveBeenCalledWith(['a']);
|
||||
expect(searchChannels).toHaveBeenCalledWith(['a'], undefined);
|
||||
expect(result.current.channels).toEqual([{id: 1, name: 'One'}]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
|
@ -510,11 +510,27 @@ describe('useSearchChannels', () => {
|
|||
}));
|
||||
rerender({tags: ['new']});
|
||||
expect(searchChannels).toHaveBeenCalledTimes(2);
|
||||
expect(searchChannels).toHaveBeenLastCalledWith(['new']);
|
||||
expect(searchChannels).toHaveBeenLastCalledWith(['new'], undefined);
|
||||
expect(result.current.channels).toBeNull();
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
test('searches by the search text, and refetches when it changes', async () => {
|
||||
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
|
||||
const {result, rerender} = renderHook(({q}) => useSearchChannels([], q), {initialProps: {q: 'cook'}});
|
||||
await act(async () => {
|
||||
});
|
||||
expect(searchChannels).toHaveBeenLastCalledWith([], 'cook');
|
||||
expect(result.current.channels).toEqual([{id: 1, name: 'Cooking'}]);
|
||||
|
||||
searchChannels.mockReturnValue(new Promise(() => {
|
||||
}));
|
||||
rerender({q: 'garden'});
|
||||
expect(searchChannels).toHaveBeenCalledTimes(2);
|
||||
expect(searchChannels).toHaveBeenLastCalledWith([], 'garden');
|
||||
expect(result.current.channels).toBeNull();
|
||||
});
|
||||
|
||||
test('a new array with the same tags does not refetch', async () => {
|
||||
searchChannels.mockResolvedValue({channels: []});
|
||||
const {rerender} = renderHook(({tags}) => useSearchChannels(tags), {initialProps: {tags: ['a']}});
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ async def channel_tag(request: Request, channel_id: int, body: schema.ChannelTag
|
|||
@validate(schema.ChannelSearchRequest)
|
||||
async def channel_search(request: Request, body: schema.ChannelSearchRequest):
|
||||
session = request.ctx.session
|
||||
channels = await lib.search_channels(session, body.tag_names)
|
||||
channels = await lib.search_channels(session, body.tag_names, body.search_str)
|
||||
ret = dict(
|
||||
channels=channels,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import pathlib
|
||||
from pathlib import Path
|
||||
from typing import List, Union
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from sqlalchemy import or_, func, desc
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy.orm import Query, Session, joinedload
|
||||
|
||||
from wrolpi import flags
|
||||
from wrolpi.collections import Collection
|
||||
|
|
@ -330,8 +330,27 @@ async def tag_channel(session: Session, tag_name: str | None, directory: pathlib
|
|||
save_channels_config.activate_switch()
|
||||
|
||||
|
||||
async def search_channels(session: Session, tag_names: List[str]) -> List[Channel]:
|
||||
"""Search Tagged Channels."""
|
||||
channels = session.query(Channel).options(*Channel.json_options()) \
|
||||
.join(Collection).join(Tag).filter(Tag.name.in_(tag_names)).all()
|
||||
return channels
|
||||
def search_channels_query(session: Session, tag_names: List[str] = None, search_str: str = None) -> Optional[Query]:
|
||||
"""Channels whose name contains `search_str` and which have any of the Tags. None when there is nothing to search
|
||||
by."""
|
||||
search_str = (search_str or '').strip()
|
||||
if not tag_names and not search_str:
|
||||
return None
|
||||
query = session.query(Channel).join(Collection, Collection.id == Channel.collection_id)
|
||||
if search_str:
|
||||
name_no_spaces = ''.join(search_str.split(' '))
|
||||
query = query.filter(or_(
|
||||
Collection.name.ilike(f'%{search_str}%'),
|
||||
Collection.name.ilike(f'%{name_no_spaces}%'),
|
||||
))
|
||||
if tag_names:
|
||||
query = query.join(Tag, Tag.id == Collection.tag_id).filter(Tag.name.in_(tag_names))
|
||||
return query
|
||||
|
||||
|
||||
async def search_channels(session: Session, tag_names: List[str] = None, search_str: str = None) -> List[Channel]:
|
||||
"""Search Channels by their name and/or Tags."""
|
||||
query = search_channels_query(session, tag_names, search_str)
|
||||
if query is None:
|
||||
return []
|
||||
return query.options(*Channel.json_options()).order_by(func.lower(Collection.name)).all()
|
||||
|
|
|
|||
|
|
@ -721,3 +721,32 @@ async def test_search_tagged_channels(async_client, test_session, channel_factor
|
|||
assert response.status_code == HTTPStatus.OK
|
||||
assert len(response.json['channels']) == 1, 'Only one Channel is tagged'
|
||||
assert response.json['channels'][0]['id'] == channel1.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_channels_by_name(async_client, test_session, channel_factory, tag_factory):
|
||||
"""Channels can be searched by their name, alone or with Tags."""
|
||||
tag = await tag_factory()
|
||||
cooking = channel_factory(name='Cooking Show', tag_name=tag.name)
|
||||
channel_factory(name='Cooking Basics')
|
||||
channel_factory(name='Gardening')
|
||||
test_session.commit()
|
||||
|
||||
async def search(body):
|
||||
request, response = await async_client.post('/api/videos/channels/search', json=body)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
return sorted(i['name'] for i in response.json['channels'])
|
||||
|
||||
assert await search(dict(search_str='cooking')) == ['Cooking Basics', 'Cooking Show']
|
||||
# The name and the Tags must both match.
|
||||
assert await search(dict(search_str='cooking', tag_names=[tag.name])) == ['Cooking Show']
|
||||
assert await search(dict(search_str='gardening', tag_names=[tag.name])) == []
|
||||
# Nothing to search by matches nothing.
|
||||
assert await search(dict(search_str='')) == []
|
||||
assert await search(dict()) == []
|
||||
|
||||
# The Other tab's estimate counts the same Channels.
|
||||
for body in (dict(search_str='cooking'), dict(search_str='cooking', tag_names=[tag.name]), dict(search_str='')):
|
||||
request, response = await async_client.post('/api/search_other_estimates', json=body)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['others']['channel_count'] == len(await search(body)), body
|
||||
|
|
|
|||
|
|
@ -266,6 +266,7 @@ class CensoredVideoResponse:
|
|||
@dataclass
|
||||
class ChannelSearchRequest:
|
||||
tag_names: List[str] = field(default_factory=list)
|
||||
search_str: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -2870,31 +2870,15 @@ def extract_headlines(entries: List[str], search_str: str) -> List[Tuple[str, fl
|
|||
|
||||
|
||||
async def search_other_estimates(session: Session, tag_names: List[str], search_str: str = None) -> dict:
|
||||
"""Estimate other things that are Tagged, or match the search (Repos by their name and README)."""
|
||||
from sqlalchemy import func
|
||||
from wrolpi.collections.models import Collection
|
||||
from wrolpi.tags import Tag
|
||||
from modules.videos.models import Channel
|
||||
"""Estimate other things that match the search or the Tags (Channels by their name, Repos by their name and
|
||||
README)."""
|
||||
from modules.videos.channel.lib import search_channels_query
|
||||
from modules.repos.lib import count_repos
|
||||
|
||||
repo_count = count_repos(session, search_str, tag_names)
|
||||
|
||||
if not tag_names:
|
||||
return dict(
|
||||
channel_count=0,
|
||||
repo_count=repo_count,
|
||||
)
|
||||
|
||||
# Matches `search_channels`: Channels with any of the Tags.
|
||||
channel_count = session.query(func.count(Channel.id)) \
|
||||
.join(Collection, Collection.id == Channel.collection_id) \
|
||||
.join(Tag, Tag.id == Collection.tag_id) \
|
||||
.filter(Tag.name.in_(tag_names)) \
|
||||
.scalar()
|
||||
|
||||
query = search_channels_query(session, tag_names, search_str)
|
||||
others = dict(
|
||||
channel_count=channel_count,
|
||||
repo_count=repo_count,
|
||||
channel_count=query.count() if query is not None else 0,
|
||||
repo_count=count_repos(session, search_str, tag_names),
|
||||
)
|
||||
return others
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue