open-navigator / scripts /discovery /champds_client.py
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
11.2 kB
"""
Champ Digital Solutions (ChampDS / EarthChannel) API helpers.
Gwinnett County embeds TV Gwinnett via ``play.champds.com/{customer}/archive/{id}``.
The browser loads JSON from ``playapi.champds.com`` (``play`` + ``api.`` + rest of host).
VOD HLS URLs are built from ``ServicesAndMachineInfo`` + ``MediaInfo.VOD2`` on each event.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Mapping, Optional, Sequence
from urllib.parse import urlparse
import requests
_DEFAULT_UA = "OpenNavigatorChampDS/1.0"
_PLAY_HOST_RE = re.compile(
r"^https?://play\.champds\.com/(?P<customer>[a-zA-Z0-9_]+)/archive/(?P<archive_id>\d+)",
re.I,
)
@dataclass(frozen=True)
class ChampDsArchiveConfig:
customer_access_id: str
archive_id: int
archive_title: str = ""
archive_groups: tuple[dict[str, Any], ...] = ()
@dataclass
class ChampDsEvent:
customer_event_id: int
event_title: str
event_datetime_utc: str
event_datetime_local: str = ""
media_info: dict[str, Any] = field(default_factory=dict)
agenda_attachments: list[dict[str, Any]] = field(default_factory=list)
minutes_attachments: list[dict[str, Any]] = field(default_factory=list)
raw: dict[str, Any] = field(default_factory=dict)
@property
def has_media(self) -> bool:
mi = self.media_info or {}
return bool((mi.get("MediaType") or "").strip()) and (
(mi.get("VOD2") or "").strip() or (mi.get("MediaPath") or "").strip()
)
def champds_api_host(play_host: str) -> str:
"""``play.champds.com`` → ``playapi.champds.com`` (see ``cds.common.js``)."""
host = (play_host or "play.champds.com").strip().lower()
parts = host.split(".")
if len(parts) >= 3 and parts[0] == "play":
return f"{parts[0]}api.{parts[1]}.{parts[2]}"
return host
def parse_champds_archive_embed_url(url: str) -> tuple[str, int] | None:
m = _PLAY_HOST_RE.match((url or "").strip())
if not m:
return None
return m.group("customer"), int(m.group("archive_id"))
class ChampDsClient:
def __init__(
self,
*,
customer_access_id: str,
play_host: str = "play.champds.com",
session: requests.Session | None = None,
user_agent: str = _DEFAULT_UA,
) -> None:
self.customer_access_id = (customer_access_id or "").strip()
if not self.customer_access_id:
raise ValueError("customer_access_id is required")
self.play_host = (play_host or "play.champds.com").strip()
self.api_host = champds_api_host(self.play_host)
self.session = session or requests.Session()
self.session.headers.setdefault("User-Agent", user_agent)
self.session.headers.setdefault("Accept", "application/json, text/javascript, */*; q=0.01")
self._warmed = False
@property
def play_base(self) -> str:
return f"https://{self.play_host}"
@property
def api_base(self) -> str:
return f"https://{self.api_host}/{self.customer_access_id}"
def warm_session(self, *, customer_event_id: int | None = None) -> None:
"""Set cookies so ``playapi`` returns full event JSON (not just ``Customer``)."""
if self._warmed and customer_event_id is None:
return
path = (
f"/{self.customer_access_id}/event/{int(customer_event_id)}"
if customer_event_id is not None
else f"/{self.customer_access_id}/archive/1"
)
self.session.get(
f"{self.play_base}{path}",
timeout=60,
headers={"Referer": self.play_base},
)
self._warmed = True
def _get_json(self, path: str, *, referer: str | None = None) -> Any:
url = f"{self.api_base}{path}"
ref = referer or self.play_base
r = self.session.get(url, timeout=120, headers={"Referer": ref})
r.raise_for_status()
return r.json()
def fetch_archive(self, archive_id: int) -> ChampDsArchiveConfig:
self.warm_session()
data = self._get_json(f"/archive/{int(archive_id)}")
arch = data.get("Archive") or {}
groups = tuple(data.get("ArchiveGroups") or [])
return ChampDsArchiveConfig(
customer_access_id=self.customer_access_id,
archive_id=int(archive_id),
archive_title=str(arch.get("ArchiveTitle") or ""),
archive_groups=groups,
)
def list_events_with_media(self, archive_group_id: int) -> list[dict[str, Any]]:
self.warm_session()
data = self._get_json(f"/archiveGroupListWithMedia/{int(archive_group_id)}")
if not isinstance(data, list):
return []
return [row for row in data if isinstance(row, dict)]
def fetch_event(self, customer_event_id: int) -> dict[str, Any]:
self.warm_session(customer_event_id=int(customer_event_id))
data = self._get_json(f"/event/{int(customer_event_id)}")
if not isinstance(data, dict):
return {}
return data
def resolve_event(self, row: Mapping[str, Any]) -> ChampDsEvent:
agenda = row.get("Agenda") if isinstance(row.get("Agenda"), dict) else {}
minutes = row.get("Minutes") if isinstance(row.get("Minutes"), dict) else {}
return ChampDsEvent(
customer_event_id=int(row.get("CustomerEventID") or 0),
event_title=str(row.get("EventTitle") or "").strip(),
event_datetime_utc=str(row.get("EventDateTimeUTC") or "").strip(),
event_datetime_local=str(row.get("EventDateTimeLocal") or "").strip(),
media_info=dict(row.get("MediaInfo") or {}),
agenda_attachments=list(agenda.get("Attachments") or []),
minutes_attachments=list(minutes.get("Attachments") or []),
raw=dict(row),
)
def enrich_event(self, event: ChampDsEvent) -> ChampDsEvent:
if not event.customer_event_id:
return event
detail = self.fetch_event(event.customer_event_id)
ev = detail.get("Event") if isinstance(detail.get("Event"), dict) else {}
agenda = detail.get("Agenda") if isinstance(detail.get("Agenda"), dict) else {}
minutes = detail.get("Minutes") if isinstance(detail.get("Minutes"), dict) else {}
media = detail.get("MediaInfo") if isinstance(detail.get("MediaInfo"), dict) else event.media_info
return ChampDsEvent(
customer_event_id=int(ev.get("CustomerEventID") or event.customer_event_id),
event_title=str(ev.get("EventTitle") or event.event_title).strip(),
event_datetime_utc=str(ev.get("EventDateTimeUTC") or event.event_datetime_utc).strip(),
event_datetime_local=str(ev.get("EventDateTimeLocal") or event.event_datetime_local).strip(),
media_info=dict(media or {}),
agenda_attachments=list(agenda.get("Attachments") or []),
minutes_attachments=list(minutes.get("Attachments") or []),
raw=detail,
)
def _svc_info_map(services: Mapping[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = {}
for k, v in (services or {}).items():
out[str(k)] = v
return out
def vod_stream_url(services_and_machine_info: Mapping[str, Any], media_info: Mapping[str, Any]) -> str:
"""
Build HLS (or legacy path) URL for a VOD row.
Mirrors ``/_COMMON/players/vjs2026/embed.js`` (``VOD2`` preferred over ``MediaPath``).
"""
svc_map = _svc_info_map(services_and_machine_info)
svc = svc_map.get("2048") or svc_map.get(2048)
if not svc:
svc = svc_map.get("8") or svc_map.get(8)
if not svc or not isinstance(svc, dict):
raise ValueError("missing ServicesAndMachineInfo VOD service (8/2048)")
url_base = str(svc.get("URLBase") or "").rstrip("/")
if not url_base:
raise ValueError("empty URLBase in ServicesAndMachineInfo")
vod2 = str(media_info.get("VOD2") or "").strip()
media_path = str(media_info.get("MediaPath") or "").strip()
if vod2:
path = vod2 if vod2.startswith("/") else f"/{vod2}"
return f"{url_base}{path}"
tmpl = str(svc.get("URLFilename") or "%MEDIA_PATH%")
clip = tmpl.replace("%MEDIA_PATH%", media_path)
if not clip.startswith("/"):
clip = f"/{clip}"
return f"{url_base}{clip}"
def attachment_download_url(
customer_access_id: str,
attachment: Mapping[str, Any],
*,
play_host: str = "play.champds.com",
) -> str | None:
loc = str(attachment.get("MediaFileLocation") or "").strip()
name = str(attachment.get("MediaFileName") or "").strip()
if attachment.get("MediaTypeID") == 2:
url = str(attachment.get("MediaFileName") or "").strip()
return url if url.lower().startswith("http") else None
if not loc or not name:
return None
return f"https://{play_host}/ATT/{customer_access_id}/{loc}/{name}"
def _parse_event_dt(event: ChampDsEvent) -> datetime:
for raw in (event.event_datetime_utc, event.event_datetime_local):
s = (raw or "").strip()
if not s:
continue
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(s[:19], fmt)
except ValueError:
continue
return datetime.min
def recent_events_with_media(
rows: Sequence[Mapping[str, Any]],
*,
limit: int = 10,
enrich: bool = False,
client: ChampDsClient | None = None,
) -> list[ChampDsEvent]:
events: list[ChampDsEvent] = []
for row in rows:
ev = ChampDsEvent(
customer_event_id=int(row.get("CustomerEventID") or 0),
event_title=str(row.get("EventTitle") or "").strip(),
event_datetime_utc=str(row.get("EventDateTimeUTC") or "").strip(),
event_datetime_local=str(row.get("EventDateTimeLocal") or "").strip(),
media_info=dict(row.get("MediaInfo") or {}),
agenda_attachments=list((row.get("Agenda") or {}).get("Attachments") or [])
if isinstance(row.get("Agenda"), dict)
else [],
minutes_attachments=list((row.get("Minutes") or {}).get("Attachments") or [])
if isinstance(row.get("Minutes"), dict)
else [],
raw=dict(row),
)
if not ev.has_media:
continue
events.append(ev)
events.sort(key=_parse_event_dt, reverse=True)
if limit > 0:
events = events[:limit]
if enrich and client is not None:
events = [client.enrich_event(ev) for ev in events]
return events
def board_of_commissioners_group_id(archive: ChampDsArchiveConfig) -> int | None:
for ag in archive.archive_groups:
name = str(ag.get("GroupName") or "").lower()
if "board of commissioners" in name and "planning" not in name:
return int(ag.get("CustomerArchiveGroupID") or ag.get("ArchiveGroupID") or 0) or None
if archive.archive_groups:
first = archive.archive_groups[0]
return int(first.get("CustomerArchiveGroupID") or first.get("ArchiveGroupID") or 0) or None
return None