mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
Other search tab: search Channels by name, add Domains and Playlists, fix the estimate (#785)
* 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:
parent
93d878952c
commit
a1292005ce
14 changed files with 309 additions and 73 deletions
|
|
@ -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')))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue