"""dublab: Los Angeles community radio, with a public weekly schedule.""" from __future__ import annotations import contextlib import time from datetime import datetime, timezone from typing import Any import httpx from ... import config from ...models import Broadcast, parse_date from ..base import fetch_json from .base import CommunityProvider SCHEDULE_URL = "https://dublab.com/.netlify/functions/schedule" def parse_schedule(payload: Any) -> list[Broadcast]: """The site's own calendar: ``[{startTime, endTime, summary, ...}]``.""" if not isinstance(payload, list): return [] broadcasts: list[Broadcast] = [] for entry in payload: if not isinstance(entry, dict): continue name = str(entry.get("summary") or entry.get("title") or "").strip() start = parse_date(entry.get("startTime") or entry.get("start")) end = parse_date(entry.get("endTime") or entry.get("end")) if not name or start is None: continue broadcasts.append(Broadcast(name=name, start=start, end=end)) broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc)) return broadcasts class DublabProvider(CommunityProvider): id = "dublab" name = "dublab" homepage = "dublab.com" brand = "DUBLAB" tagline = "community radio · los angeles · since 1999" station_source = "dublab.com schedule" history_source = "dublab.com schedule (rolling)" station_title = "dublab" station_description = "non-profit community radio from Los Angeles, since 1999" station_genre = "eclectic|experimental" streams = (("https://dublab.out.airtime.pro/dublab_a", "mp3", "high", "192"),) # The schedule is the only metadata dublab publishes, so it drives both the # left pane and "what is on". has_schedule = True wordmark = "DUBLAB" hero_lines = ("LOS ANGELES", "SINCE 1999") async def fetch_schedule(self, client: httpx.AsyncClient) -> list[Broadcast]: try: payload = await fetch_json(client, SCHEDULE_URL, attempts=1) except RuntimeError: return self.load_cached_schedule() entries = self._mark_live(parse_schedule(payload)) if not entries: return self.load_cached_schedule() config.write_json( config.schedule_cache_path(self.id), { "fetched": time.time(), "schedule": [ { "name": item.name, "start": item.start.isoformat() if item.start else "", "end": item.end.isoformat() if item.end else "", } for item in entries ], }, ) return entries async def fetch_history(self, client: httpx.AsyncClient, station_id: str): # The schedule is the only "what is on" dublab publishes, so make sure # we have one before the base class looks for the running show. if not self.load_cached_schedule(): with contextlib.suppress(Exception): await self.fetch_schedule(client) return await super().fetch_history(client, station_id) def load_cached_schedule(self) -> list[Broadcast]: raw = config.read_json(config.schedule_cache_path(self.id)) entries = raw.get("schedule") if isinstance(raw, dict) else None if not isinstance(entries, list): return [] broadcasts: list[Broadcast] = [] for entry in entries: if not isinstance(entry, dict): continue start = parse_date(entry.get("start")) name = str(entry.get("name") or "").strip() if not name or start is None: continue broadcasts.append( Broadcast(name=name, start=start, end=parse_date(entry.get("end"))) ) broadcasts.sort(key=lambda item: item.start or datetime.now(timezone.utc)) return broadcasts