"""Search and paginate the public live TV catalog at the database boundary.""" from typing import Any from sqlalchemy import Select, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.time import to_iso8601_utc from app.models.collected_data import CollectedData from app.services.tv_streams import ( TV_LIVE_SOURCE_COLLECTOR, TV_LIVE_SOURCE_DATA_TYPE, _build_collected_tv_source, build_public_tv_payload, get_tv_settings_payload, ) def _source_key(): return func.coalesce( func.nullif(CollectedData.extra_data["id"].as_string(), ""), func.nullif(CollectedData.source_id, ""), CollectedData.entity_key, ) def _collected_catalog_query(configured_ids: list[str]) -> Select[tuple[CollectedData]]: metadata = CollectedData.extra_data source_id = _source_key() enabled = func.lower(func.trim(func.coalesce(metadata["is_enabled"].as_string(), "true"))) ranked = ( select( CollectedData.id, func.row_number() .over( partition_by=source_id, order_by=CollectedData.id.desc(), ) .label("source_rank"), ) .where( CollectedData.source == TV_LIVE_SOURCE_COLLECTOR, CollectedData.data_type == TV_LIVE_SOURCE_DATA_TYPE, CollectedData.is_current.is_(True), CollectedData.is_valid == 1, CollectedData.deleted_at.is_(None), enabled.notin_(("false", "0", "no", "off")), source_id.notin_(configured_ids), ) .subquery() ) return ( select(CollectedData) .join(ranked, ranked.c.id == CollectedData.id) .where(ranked.c.source_rank == 1) ) def _filter_catalog_query( query: Select[tuple[CollectedData]], terms: list[str] ) -> Select[tuple[CollectedData]]: metadata = CollectedData.extra_data searchable = func.lower( func.concat_ws( " ", CollectedData.name, CollectedData.title, CollectedData.source_id, metadata["name"].as_string(), metadata["provider"].as_string(), metadata["region"].as_string(), metadata["country"].as_string(), metadata["language"].as_string(), ) ) for term in terms: query = query.where(searchable.contains(term, autoescape=True)) return query def _matches_source(source: dict[str, Any], terms: list[str]) -> bool: searchable = " ".join( str(source.get(key) or "") for key in ("id", "name", "provider", "region", "language") ).lower() return all(term in searchable for term in terms) async def get_tv_catalog_page( db: AsyncSession, *, offset: int = 0, limit: int = 50, q: str = "", selected_id: str | None = None, ) -> dict[str, Any]: settings = await get_tv_settings_payload(db) payload = build_public_tv_payload(settings, []) configured = payload["sources"] query = _collected_catalog_query([source["id"] for source in settings["sources"]]) if selected_id: selected = next((source for source in configured if source["id"] == selected_id), None) if selected is None: record = await db.scalar(query.where(_source_key() == selected_id).limit(1)) selected = _build_collected_tv_source(record, 0) if record else None if selected: payload["selected_source"] = selected summary = ( await db.execute( select(func.count(), func.max(CollectedData.collected_at)) .select_from(CollectedData) .where(CollectedData.id.in_(query.with_only_columns(CollectedData.id))) ) ).one() total_collected, latest_update = summary terms = q.lower().split() matched_configured = [source for source in configured if _matches_source(source, terms)] filtered_query = _filter_catalog_query(query, terms) matched_collected = ( await db.scalar(select(func.count()).select_from(filtered_query.subquery())) if terms else total_collected ) sources = matched_configured[offset : offset + limit] remaining = limit - len(sources) if remaining: rows = await db.scalars( filtered_query.order_by(func.lower(CollectedData.name), CollectedData.id) .offset(max(0, offset - len(matched_configured))) .limit(remaining) ) sources.extend( _build_collected_tv_source(record, index) for index, record in enumerate(rows) ) total = len(matched_configured) + matched_collected next_offset = offset + len(sources) return { **payload, "sources": sources, "source_count": len(configured) + total_collected, "latest_updated_at": ( to_iso8601_utc(latest_update) if latest_update else payload["latest_updated_at"] ), "total": total, "offset": offset, "limit": limit, "has_more": next_offset < total, "next_offset": next_offset if next_offset < total else None, }