From 52cf7a95201c91f67271d84e62ed2fe61024c17d Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 7 Oct 2026 15:37:07 -0600 Subject: [PATCH] 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 --- app/src/api.js | 4 +-- app/src/components/Search.js | 2 +- app/src/hooks/customHooks.js | 7 +++--- app/src/hooks/customHooks.test.js | 20 +++++++++++++-- modules/videos/channel/api.py | 2 +- modules/videos/channel/lib.py | 33 +++++++++++++++++++------ modules/videos/channel/test/test_api.py | 29 ++++++++++++++++++++++ modules/videos/schema.py | 1 + wrolpi/common.py | 28 +++++---------------- 9 files changed, 88 insertions(+), 38 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 142f7ec3..6b2b9673 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -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(); diff --git a/app/src/components/Search.js b/app/src/components/Search.js index 727ef304..daf72924 100644 --- a/app/src/components/Search.js +++ b/app/src/components/Search.js @@ -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) { diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 45bd8697..3c874721 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -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, diff --git a/app/src/hooks/customHooks.test.js b/app/src/hooks/customHooks.test.js index 9f26d925..0652557a 100644 --- a/app/src/hooks/customHooks.test.js +++ b/app/src/hooks/customHooks.test.js @@ -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']}}); diff --git a/modules/videos/channel/api.py b/modules/videos/channel/api.py index 77efa479..50a52023 100644 --- a/modules/videos/channel/api.py +++ b/modules/videos/channel/api.py @@ -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, ) diff --git a/modules/videos/channel/lib.py b/modules/videos/channel/lib.py index b4e983e2..c514c5da 100644 --- a/modules/videos/channel/lib.py +++ b/modules/videos/channel/lib.py @@ -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() diff --git a/modules/videos/channel/test/test_api.py b/modules/videos/channel/test/test_api.py index 883f41bf..578a5a85 100644 --- a/modules/videos/channel/test/test_api.py +++ b/modules/videos/channel/test/test_api.py @@ -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 diff --git a/modules/videos/schema.py b/modules/videos/schema.py index f3a73d80..0845b7db 100644 --- a/modules/videos/schema.py +++ b/modules/videos/schema.py @@ -266,6 +266,7 @@ class CensoredVideoResponse: @dataclass class ChannelSearchRequest: tag_names: List[str] = field(default_factory=list) + search_str: Optional[str] = None @dataclass diff --git a/wrolpi/common.py b/wrolpi/common.py index 321478f5..91cf6cd4 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -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