This is an AI-built proof of concept, not a product made by people. It was written by an AI coding assistant to show what is possible when you build your own directories and stream listings on top of Owncast. It is a demonstration and a starting point, nothing more. Do not treat it as an official, supported, or production-ready piece of software.
A small reference application that follows Owncast servers over ActivityPub, tracks which ones are live, accepts operator submissions, and serves a single web page listing them. Live servers appear first with their thumbnail, name, and title. Offline servers are listed below.
It is built to be read and forked. The whole thing is a handful of short Python files with one SQLite table behind them.
The goal is to hopefully have people build a far more productized solution for people to use in the future, so the idea of personal and communal directories of Owncast live streams can become a thing.
This is a minimal reference, not a production service. It has no accounts, no admin UI, no rate limiting, and no scaling story. It keeps everything in one process and one SQLite file. Read it, learn from it, and build your real directory on top of the ideas here rather than shipping this as is.
The directory is itself a Fediverse actor. It publishes an ActivityStreams actor document with an RSA public key, served over HTTPS, plus WebFinger and NodeInfo, so an Owncast server can resolve it and verify the requests it sends.
For each server it lists, the flow is:
- Follow. The directory sends a signed
Followto the server's actor. - Accept. The server posts an
Acceptback to the directory's inbox. The entry is now followed. - Offer. While the server is live it posts an
Offerto the inbox about every five minutes, carrying Owncast's custom metadata (title, description, server name, thumbnail, tags). The directory marks the entry online and fills it in from those fields. The logo is not taken from the activity: the page derives it from the server URL (/logo/external), a value it trusts, rather than the logo URL the remote server sends. - Leave. When the stream ends the server posts a
Leave, and the entry goes offline. - Staleness sweep. If an online entry stops receiving
Offerpings without aLeave(a crash or a network drop), a once-a-minute sweep marks it offline after about eleven minutes.
Owncast has no built-in way for a server to ask to be listed, so the directory
provides a submission form at /submit. An operator pastes their server URL, the
directory checks it is a featurable Owncast server through NodeInfo, and the
submission waits for review. The directory owner approves or rejects it at
/review. On approval the directory follows the server and the flow above takes
over.
Remote metadata is treated as untrusted. URL fields are confirmed to be http
or https before they are rendered, text is length-clamped, and the templates
escape everything. The one value the directory trusts is the server URL it chose
to follow, not the display name the server sends.
| File | Responsibility |
|---|---|
directory/config.py |
Configuration from environment variables |
directory/store.py |
SQLite storage, one row per server |
directory/owncast.py |
NodeInfo validation and Owncast metadata parsing and sanitizing |
directory/federation.py |
Keypair, actor document, signed requests, signature verification, inbox dispatch |
directory/app.py |
Quart routes, the submission and review flow, the page, the sweep |
directory/templates.py |
The HTML |
directory/cli.py |
A tiny review command |
directory/__main__.py |
Entry point |
The ActivityPub layer is built on bovine, which does the cryptography, the signing, and the signature verification. The web framework is Quart, which bovine's inbound signature validator is designed to work with directly.
- Python 3.11 or newer.
- uv for dependency management. The dependencies
(
bovine,quart,hypercorn) are pinned inpyproject.tomland locked inuv.lock.
uv syncThat creates a .venv and installs the exact locked versions. uv will fetch a
suitable Python for you if you do not have one.
If you would rather not use uv, the project is a standard pyproject.toml, so
pip install . into a virtual environment works too.
Everything is an environment variable. See env.example for the full list with
defaults and comments. The ones you are most likely to set:
DIRECTORY_DOMAIN: required, no default. The public host the directory runs on, with no scheme. The actor is always served over HTTPS using this host, and it is the keyId other servers fetch to verify the directory's signatures, so set it to a host they can reach. The app refuses to start without it.DIRECTORY_STALE_SECONDS: how long a live entry may go without anOfferping before it is marked offline (default 660, about 11 minutes).
uv run owncast-directoryThat console script is the entry point. uv run python -m directory does the
same thing. The app serves plain HTTP on DIRECTORY_BIND_HOST:DIRECTORY_BIND_PORT (default
127.0.0.1:8000). It does not terminate TLS itself. Put an HTTPS reverse proxy
in front of it on the host named by DIRECTORY_DOMAIN, because Owncast signs
every request and will only deliver to an HTTPS inbox. On first run it generates
an RSA keypair under DIRECTORY_KEY_DIR and reuses it afterwards. Keep that
keypair: it is the actor's identity.
Endpoints:
GET /the public directory page.GET /submitandPOST /submitthe submission form.GET /reviewandPOST /reviewthe review page (no auth, since this is a demo).GET /activitypub/actorthe actor document.POST /activitypub/actor/inboxthe inbox.GET /.well-known/webfinger,GET /.well-known/nodeinfo,GET /nodeinfo/2.0discovery.
This assumes a publicly reachable Owncast server (v0.3.0 or newer, with featured
streams enabled) and a publicly reachable directory, so the two can sign and
deliver requests to each other over HTTPS. Below, replace
https://directory.example.com with your directory's own domain.
Every server is added by hand and approved by hand. There are no seeds and no auto-accept. Listing a server is two explicit steps on two pages.
Open the submission form and enter the server's URL:
https://directory.example.com/submit
The directory checks, through NodeInfo, that the URL is a featurable Owncast server, and rejects anything that is not Owncast or is unreachable with a clear message. A valid submission is stored as pending and is not listed yet.
Open the review page. It lists every pending submission with Approve and Reject buttons:
https://directory.example.com/review
This page has no password, because this is a demo. A real directory would put it
behind a login, an allowlist, or a private network. (If you prefer the command
line, it edits the same database the running app reads: uv run python -m directory.cli list, then approve <url> or reject <url>.)
On approval the directory sends the server a signed Follow carrying the
https://owncast.online/ns#directory marker.
That marker tells Owncast the follower is a directory, so Owncast does not auto-accept it. It holds the request for the operator to approve in their Owncast admin under Featured Streams. Being listed is opt-in on their side by design, so the entry stays pending and unlisted until they approve.
Once the operator approves, the server shows on the directory page, offline at
first. While it is live it sends periodic Offer pings and the entry shows live
with its title, thumbnail, and tags. When the stream ends it sends a Leave and
the entry returns to offline. If the server drops off without a clean Leave,
the staleness sweep ages the entry out (see DIRECTORY_STALE_SECONDS).
If the operator later removes the directory from their Featured Streams admin,
Owncast sends the directory a Reject and the entry is dropped from the listing.
MIT. See LICENSE. bovine is also MIT licensed.