Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 69 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ Public web API for [openhomefoundation.org](https://www.openhomefoundation.org).

It currently serves the livestream status of the Open Home Foundation's YouTube
channels — Home Assistant, ESPHome, Open Home Foundation and Music Assistant —
so the project websites can show upcoming, live and recently-ended streams.
so the project websites can show upcoming, live and recently-ended streams, and
the events of the foundation's Luma calendars, read from their iCalendar feeds,
so the sites can show meetups and other community events.

Built to the [OHF engineering standards](https://standards.openhomefoundation.org):
NestJS on Node LTS, TypeScript in strict mode, pnpm, and Mise for task running.
Expand All @@ -31,6 +33,8 @@ Interactive documentation is generated from the code and served at
| ------ | ------------------- | ------------------------------------------------- |
| `GET` | `/livestream` | Livestream status for every configured channel |
| `GET` | `/livestream/:slug` | Status for one channel; `404` for an unknown slug |
| `GET` | `/events` | Every configured Luma calendar with its events |
| `GET` | `/events/:slug` | One calendar's events; `404` for an unknown slug |
| `GET` | `/__heartbeat__` | Application health probe |
| `GET` | `/__lbheartbeat__` | Load-balancer probe |
| `GET` | `/__version__` | Running build's version and commit |
Expand All @@ -54,15 +58,47 @@ A livestream entry looks like this:
describe. `updatedAt` changes only when the reported state changes, so it is safe
to use for caching and change detection.

An events calendar entry looks like this:

```json
{
"calendar": "home-assistant-meetups",
"calendarName": "Home Assistant Meetups",
"events": [
{
"id": "evt-HJ5eO3aJOiCob3z@events.lu.ma",
"summary": "Dublin - Hosted by the OHF",
"start": "2026-06-04T17:30:00.000Z",
"end": "2026-06-04T20:30:00.000Z",
"description": "Get up-to-date information at: https://luma.com/n5mzdtvb",
"location": "26 Wexford St, Portobello, Dublin, D02 HX93, Ireland",
"url": "https://luma.com/n5mzdtvb",
"host": "Missy Quarry",
"latitude": 53.336691,
"longitude": -6.26573,
"status": "tentative"
}
],
"updatedAt": "2026-08-18T12:00:00.000Z"
}
```

Events are everything the calendar's Luma feed advertises — past ones included,
sorted soonest first — so the consumer decides the window it shows. Times are
UTC; an all-day event carries a bare `YYYY-MM-DD` date instead. `url` is the
event's Luma page, and `host` is who is hosting — both lifted from the feed's
templated description, which is the only place Luma carries them. As with
livestreams, `updatedAt` moves only when the served content changes.

Every response carries the security headers [helmet](https://helmetjs.github.io)
applies by default, including a `Content-Security-Policy`, `nosniff`, and HSTS.
The defaults are used unchanged; `test/security.e2e-spec.ts` asserts them and
checks that the policy still fits what the Swagger UI at `/docs` needs.

Reads are open to no one by default and to the origins in `CORS_ORIGINS` when it
is set — the same list the Socket.IO endpoint honours, which refuses a handshake
from an origin that is not on it. A `404` reports only that the channel is
unknown, without repeating the requested slug back. Rate limiting belongs to
from an origin that is not on it. A `404` reports only that the channel or
calendar is unknown, without repeating the requested slug back. Rate limiting belongs to
Cloudflare in front of this service, not to the app — see
[Configuration](#configuration).

Expand All @@ -72,12 +108,13 @@ Configuration is environment variables only. `example.env` documents every one;
copy it to `.env` for local development (`.env` is gitignored and must never be
committed). In production these are set on the container.

| Variable | Required | Purpose |
| --------------------- | -------- | ------------------------------------------------ |
| `YOUTUBE_API_KEY` | yes | YouTube Data API v3 key, used to classify videos |
| `LIVESTREAM_CHANNELS` | yes | Channels to track, as `handle:slug` pairs |
| `CORS_ORIGINS` | no | Sites allowed to read the API from a browser |
| `PORT` | no | Listen port, defaults to `3000` |
| Variable | Required | Purpose |
| --------------------- | -------- | --------------------------------------------------- |
| `YOUTUBE_API_KEY` | yes | YouTube Data API v3 key, used to classify videos |
| `LIVESTREAM_CHANNELS` | yes | Channels to track, as `handle:slug` pairs |
| `EVENTS_CALENDARS` | yes | Luma calendars to serve, as `calendarId:slug` pairs |
| `CORS_ORIGINS` | no | Sites allowed to read the API from a browser |
| `PORT` | no | Listen port, defaults to `3000` |

`LIVESTREAM_CHANNELS` is a comma-separated list of `handle:slug` pairs:

Expand All @@ -96,6 +133,23 @@ configured. Adding or removing a project is a configuration change, not a code
change. Malformed configuration fails startup rather than silently tracking
nothing.

`EVENTS_CALENDARS` works the same way for Luma calendars, as a comma-separated
list of `calendarId:slug` pairs:

```
EVENTS_CALENDARS=cal-6Tm2FkWzoBpLXWr:home-assistant-meetups
```

- `calendarId` is the Luma calendar ID the iCalendar feed is fetched by
(`api.luma.com/ics/get?entity=calendar&id=<calendarId>`).
- `slug` is the path this API serves the calendar under (`/events/<slug>`) and
the `calendar` field in the response — pinned in configuration for the same
reason channel slugs are.

Calendar display names come from each feed's `X-WR-CALNAME` at runtime. The
feeds are public, so no API key is involved; they are re-fetched every 15
minutes, and a calendar whose fetch fails keeps serving what it served before.

`CORS_ORIGINS` is a comma-separated list of the origins allowed to read the API
from a browser — the sites that consume it are deployed separately, so this is
configuration too:
Expand Down Expand Up @@ -149,9 +203,12 @@ videos.list (quota) ─▶ reconcile poll ──┘
notifies on uploads and metadata edits, not on a broadcast going live, so it
cannot deliver the signal this service is about.

Architecturally the feature is one Nest module (`src/livestream`) with a
controller over an in-memory state map; there is no database. State is rebuilt
from YouTube on every boot.
Events are simpler: one fetch of each calendar's iCalendar feed every 15
minutes, parsed in place — no API key, no quota, no per-event classification.

Architecturally each feature is one Nest module (`src/livestream`, `src/events`)
with a controller over an in-memory state map; there is no database. State is
rebuilt from the upstream feeds on every boot.

The C4 diagrams in [`docs/architecture`](docs/architecture) say the same thing at
each level the [OHF architecture standards](https://standards.openhomefoundation.org/architecture/c4-documentation/)
Expand Down
58 changes: 55 additions & 3 deletions docs/architecture/model.c4
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ model {
}
}

luma = external 'Luma' {
technology 'iCalendar over HTTPS'
description '''
Hosts the foundation's community-event calendars. Their public iCalendar
exports (api.luma.com/ics/get?entity=calendar&id=…) are the source of
every event this API serves — unauthenticated and unmetered, so unlike
YouTube there is no quota to design around.
'''
}

// The two elements below are described by the `deployments` repository
// (Terraform Cloud workspace "web-api"), not by this one.
edge = external 'Cloudflare' {
Expand Down Expand Up @@ -118,15 +128,17 @@ model {
description '''
Public, read-only HTTP API for openhomefoundation.org. It reports the
livestream status of the foundation's YouTube channels — live, upcoming,
recently ended, or nothing — so the project websites can show it without
each of them talking to YouTube.
recently ended, or nothing — and the events of the foundation's Luma
calendars, so the project websites can show both without each of them
talking to YouTube or Luma.
'''

api = container 'Web API service' {
technology 'NestJS 11 / Express on Node 24, port 3000'
description '''
One process, one container image. It answers HTTP requests and runs the
two YouTube polling loops on timers side by side.
polling loops — two against YouTube, one against Luma — on timers side
by side.

There is no database and no cache container: all state is in the
process, and it is rebuilt from YouTube on every boot. That is the
Expand Down Expand Up @@ -159,6 +171,15 @@ model {
'''
}

eventsController = component 'Events controller' {
technology 'src/events/events.controller.ts'
description '''
GET /events and GET /events/:slug. Like the livestream endpoints, a
pure read of already parsed state: a request never triggers a call to
Luma, and an unknown slug is a 404.
'''
}

healthController = component 'Health controller' {
technology 'src/health/health.controller.ts'
description '''
Expand All @@ -178,6 +199,15 @@ model {
'''
}

calendarConfig = component 'Calendar configuration' {
technology 'src/events/events.calendars.ts'
description '''
Parses EVENTS_CALENDARS into validated calendarId:slug pairs once, at
boot — the same contract as the channel list: configuration change,
not code change, and malformed configuration fails startup.
'''
}

livestreamService = component 'Livestream service' {
technology 'src/livestream/livestream.service.ts'
description '''
Expand All @@ -188,6 +218,23 @@ model {
'''
}

eventsService = component 'Events service' {
technology 'src/events/events.service.ts'
description '''
Fetches each calendar's iCalendar feed every 15 minutes, parses it in
place, and keeps the last successfully parsed content — a calendar
whose fetch fails serves stale events rather than none.
'''
}

eventsState = store 'Calendar state' {
technology 'In-process Map'
description '''
The ready-to-serve event list per calendar slug. Not a database for
the same reason channel state is not: one refresh rebuilds it.
'''
}

state = store 'Channel state' {
technology 'In-process Maps'
description '''
Expand Down Expand Up @@ -221,11 +268,15 @@ model {
// middleware in registration order, so the security headers and the CORS
// decision really do come first.
bootstrap -[inproc]-> livestreamController 'routes /livestream'
bootstrap -[inproc]-> eventsController 'routes /events'
bootstrap -[inproc]-> healthController 'routes the probe endpoints'
bootstrap -[inproc]-> gateway 'admits handshakes from the configured origins'
livestreamController -[inproc]-> livestreamService 'reads the current status'
livestreamService -[inproc]-> state 'derives from, and writes back'
channelConfig -[inproc]-> livestreamService 'injects the validated channel list at boot'
eventsController -[inproc]-> eventsService 'reads the parsed events'
eventsService -[inproc]-> eventsState 'parses into, and reads back'
calendarConfig -[inproc]-> eventsService 'injects the validated calendar list at boot'
}
}

Expand All @@ -240,6 +291,7 @@ model {

livestreamService -[atom]-> feeds 'polls each channel feed every 5 min; skips the classify step when unchanged'
livestreamService -[rest]-> dataApi 'resolves handles, and classifies changed or imminent videos'
eventsService -[ical]-> luma 'fetches each calendar feed every 15 min; keeps the last good content on failure'

// Delivery path, as the release workflow performs it.
engineer -> github 'merges, and publishes a release'
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture/specification.c4
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ specification {
technology 'Atom XML over HTTPS'
}

relationship ical {
technology 'iCalendar over HTTPS'
}

relationship api {
technology 'HTTPS/JSON'
}
Expand Down
7 changes: 5 additions & 2 deletions docs/architecture/views.c4
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ views {
edge,
webapi,
youtube,
luma,
platform

autoLayout TopBottom
Expand All @@ -54,6 +55,7 @@ views {
edge,
youtube,
youtube.*,
luma,
platform

autoLayout TopBottom
Expand All @@ -65,8 +67,8 @@ views {
Inside the process. Two things are worth reading off this view: every
request passes through Bootstrap first, which is where the security
headers and the CORS decision happen; and the HTTP path never reaches
YouTube — it reads state the polling loops maintain, so a slow or failing
YouTube cannot slow down a response.
YouTube or Luma — it reads state the polling loops maintain, so a slow or
failing upstream cannot slow down a response.
'''

include
Expand All @@ -75,6 +77,7 @@ views {
edge,
youtube,
youtube.*,
luma,
platform

autoLayout TopBottom
Expand Down
13 changes: 13 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ YOUTUBE_API_KEY=
# change; malformed or missing config fails startup rather than tracking nothing.
LIVESTREAM_CHANNELS=home_assistant:home-assistant,esphomeio:esphome,OpenHomeFndn:open-home-foundation,musicassistantio:music-assistant

# Luma calendars to serve events from: a comma-separated list of
# calendarId:slug pairs.
# - calendarId: the Luma calendar ID the iCalendar feed is fetched by, e.g.
# cal-6Tm2FkWzoBpLXWr (from api.luma.com/ics/get?entity=calendar&id=…).
# - slug: the path this API serves the calendar under (/events/<slug>) and
# the "calendar" field in the response. Pinned here rather than
# derived from the calendar's Luma name so renaming the calendar
# cannot silently change our public URLs.
# Display names are read from each feed at runtime, so they are not configured
# here. Adding or removing a calendar is a config change, not a code change;
# malformed or missing config fails startup rather than tracking nothing.
EVENTS_CALENDARS=cal-6Tm2FkWzoBpLXWr:home-assistant-meetups

# Origins allowed to read this API from a browser: a comma-separated list of
# scheme-and-host entries, e.g. https://esphome.io,https://*.esphome.io.
# A port is part of an origin (http://localhost:8123); a path is not. A leading
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppGateway } from './app.gateway';
import { EventsModule } from './events';
import { HealthModule } from './health';
import { getVersionInfo } from './health/version';
import { LivestreamModule } from './livestream';
Expand All @@ -10,6 +11,7 @@ import { LivestreamModule } from './livestream';
ConfigModule.forRoot({ isGlobal: true }),
HealthModule.register({ version: getVersionInfo() }),
LivestreamModule,
EventsModule,
],
providers: [AppGateway],
})
Expand Down
Loading