109 lines
4.0 KiB
Python
109 lines
4.0 KiB
Python
"""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
|