Other search tab: search Channels by name, add Domains and Playlists, fix the estimate (#785)
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run

* Count Channels with any of the Tags in the Other search estimate

The Other tab lists Channels with any of the selected Tags, but its estimate
only counted Channels with the first Tag, so the two disagreed when several
Tags were selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

* Add Domains to the Other search tab

Domains match the search text by name and/or any of the Tags, like Channels.
The list and its estimate share one query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add Playlists to the Other search tab

Domains and Playlists are Collections, so the Other tab searches both with
the existing /api/collections/search (by kind) instead of a Domain-only
endpoint.  The search and the estimate share search_collections_query.  The
search now loads each Collection's Downloads up front instead of once per
Collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-10-07 18:22:05 -06:00 • committed by Roland
parent 93d878952c
commit a1292005ce
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
14 changed files with 309 additions and 73 deletions

View file

@ -116,13 +116,13 @@ async def test_search_api(test_session, async_client, test_wrolpi_config):
# The global search's "Other" estimate counts Repos by the search, and by Tag.
request, response = await async_client.post('/api/search_other_estimates', content=json.dumps(body))
assert response.json['others'] == dict(channel_count=0, repo_count=1)
assert response.json['others'] == dict(channel_count=0, domain_count=0, playlist_count=0, repo_count=1)
body = dict(tag_names=['software'])
request, response = await async_client.post('/api/search_other_estimates', content=json.dumps(body))
assert response.json['others'] == dict(channel_count=0, repo_count=1)
assert response.json['others'] == dict(channel_count=0, domain_count=0, playlist_count=0, repo_count=1)
body = dict(tag_names=['software'], search_str='nothing')
request, response = await async_client.post('/api/search_other_estimates', content=json.dumps(body))
assert response.json['others'] == dict(channel_count=0, repo_count=0)
assert response.json['others'] == dict(channel_count=0, domain_count=0, playlist_count=0, repo_count=0)
# Repos are suggested by name.
request, response = await async_client.post('/api/search_suggestions', content=json.dumps(dict(search_str='kiw')))

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