Files
planet/backend/app/services/tv_catalog.py
rayd1o 58671e7bc3
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.74.4
2026-09-13 10:27:00 +08:00

149 lines
5.0 KiB
Python

"""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,
}