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:
Roland 2026-10-07 15:37:07 -06:00
parent 4bd86d3eb1
commit 52cf7a9520
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
9 changed files with 88 additions and 38 deletions

View file

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

View file

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

View file

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

View file

@ -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']}});

View file

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

View file

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

View file

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

View file

@ -266,6 +266,7 @@ class CensoredVideoResponse:
@dataclass
class ChannelSearchRequest:
tag_names: List[str] = field(default_factory=list)
search_str: Optional[str] = None
@dataclass

View file

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