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>
This commit is contained in:
Roland 2026-10-07 17:06:01 -06:00
parent d0b9355beb
commit 65e3325e51
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
12 changed files with 160 additions and 167 deletions

View file

@ -68,17 +68,6 @@ async def search_archives(request: Request, body: schema.ArchiveSearchRequest):
return json_response(ret)
@archive_bp.post('/domains/search')
@openapi.definition(
description='Search Domains by their name and/or Tags',
body=schema.DomainSearchRequest,
)
@validate(schema.DomainSearchRequest)
async def domain_search(request: Request, body: schema.DomainSearchRequest):
domains = lib.search_domains(request.ctx.session, body.tag_names, body.search_str)
return json_response(dict(domains=domains))
@archive_bp.post('/file_format')
@openapi.definition(
description='Preview the archive file format',

View file

@ -16,7 +16,7 @@ from urllib.parse import urlparse
import pytz
from sqlalchemy import asc, func, BigInteger
from sqlalchemy.orm import Query, Session
from sqlalchemy.orm import Session
from modules.archive.models import Archive
from wrolpi import dates
@ -34,7 +34,7 @@ from wrolpi.files.lib import handle_file_group_search_results, cached_search_tot
search_filter_cache_key, count_file_groups
from wrolpi.files.models import FileGroup
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
from wrolpi.tags import Tag, tag_append_sub_select_where
from wrolpi.tags import tag_append_sub_select_where
from wrolpi.vars import PYTEST, DOCKERIZED
logger = logger.getChild(__name__)
@ -1555,29 +1555,6 @@ async def search_domains_by_name(session: Session, name: str, limit: int = 5) ->
]
def search_domains_query(session: Session, tag_names: List[str] = None, search_str: str = None) -> Optional[Query]:
"""Domains 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(Collection).filter(Collection.kind == 'domain')
if search_str:
query = query.filter(Collection.name.ilike(f'%{search_str}%'))
if tag_names:
query = query.join(Tag, Tag.id == Collection.tag_id).filter(Tag.name.in_(tag_names))
return query
def search_domains(session: Session, tag_names: List[str] = None, search_str: str = None) -> List[dict]:
"""Search Domains by their name and/or Tags."""
query = search_domains_query(session, tag_names, search_str)
if query is None:
return []
collections = query.order_by(func.lower(Collection.name)).all()
return [dict(id=i.id, domain=i.name, tag_name=i.tag_name) for i in collections]
async def html_to_readability(html: str | bytes, url: str, timeout: int = 120):
"""Extract the readability dict from the provided HTML."""
with tempfile.NamedTemporaryFile('wb', suffix='.html') as singlefile_file:

View file

@ -27,12 +27,6 @@ class ArchiveSearchRequest:
deep: bool = False # When True, search all text including page contents (d_text)
@dataclass
class DomainSearchRequest:
tag_names: List[str] = field(default_factory=list)
search_str: Optional[str] = None
@dataclass
class ArchiveSearchResponse:
file_groups: List[ArchiveDict]

View file

@ -1,46 +0,0 @@
from http import HTTPStatus
import pytest
from wrolpi.collections import Collection
@pytest.mark.asyncio
async def test_search_domains(async_client, test_session, tag_factory):
"""Domains can be searched by their name and/or Tags, and the Other tab's estimate counts the same Domains."""
tag1, tag2 = await tag_factory(), await tag_factory()
test_session.add_all([
Collection(name='cooking.example.com', kind='domain', tag=tag1),
Collection(name='cooking.example.org', kind='domain'),
Collection(name='gardening.example.com', kind='domain', tag=tag2),
# Not a Domain.
Collection(name='cooking channel', kind='channel', tag=tag1),
])
test_session.commit()
async def search(body):
request, response = await async_client.post('/api/archive/domains/search', json=body)
assert response.status_code == HTTPStatus.OK
return sorted(i['domain'] for i in response.json['domains'])
cases = [
(dict(search_str='cooking'), ['cooking.example.com', 'cooking.example.org']),
(dict(tag_names=[tag1.name]), ['cooking.example.com']),
# Any of the Tags.
(dict(tag_names=[tag1.name, tag2.name]), ['cooking.example.com', 'gardening.example.com']),
# The name and the Tags must both match.
(dict(search_str='example.com', tag_names=[tag2.name]), ['gardening.example.com']),
(dict(search_str='cooking', tag_names=[tag2.name]), []),
# Nothing to search by matches nothing.
(dict(search_str=''), []),
(dict(), []),
]
for body, expected in cases:
assert await search(body) == expected, body
request, response = await async_client.post('/api/search_other_estimates', json=body)
assert response.status_code == HTTPStatus.OK
assert response.json['others']['domain_count'] == len(expected), body
request, response = await async_client.post('/api/archive/domains/search', json=dict(tag_names=[tag1.name]))
domain, = response.json['domains']
assert domain['tag_name'] == tag1.name

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, domain_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, domain_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, domain_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')))