- Python 98.5%
- Dockerfile 1.5%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| rcaudio | ||
| rcaudio.egg-info | ||
| tests | ||
| .dockerignore | ||
| .gitignore | ||
| compose.yaml | ||
| Dockerfile | ||
| env.example | ||
| pyproject.toml | ||
| pytest.ini | ||
| rcaudio.service | ||
| README.md | ||
rcaudio
Radio-Canada OHdio shows as standard podcast RSS feeds, so you can subscribe in whatever podcast client you already use.
OHdio publishes freely available public-broadcasting audio but doesn't offer RSS.
rcaudio reads the show pages, caches everything in SQLite, and serves a normal
podcast feed per show. Your client polls the local server; only the background
refresh ever touches Radio-Canada.
Quick start (Docker)
cp env.example .env # set RCAUDIO_UID / RCAUDIO_GID to your `id -u` / `id -g`
mkdir -p data
docker compose up -d
That pulls the published package,
code.spacebar.ca/scarpentier/rcaudio:latest, built by the Forgejo workflow in
.forgejo/workflows/publish.yml. To build
from this checkout instead, uncomment build: . in compose.yaml.
Then open http://localhost:8477/ and paste an OHdio show link into the box. The page lists every show with its feed URL, a copy button, and refresh/remove controls — that's the whole management surface, no shell needed.
Adding a big multi-season show takes a few seconds to walk, so the page hands back immediately, marks the show fetching…, and refreshes itself until the episodes land. Copy the feed URL into your podcast client and you're done.
The CLI still works if you prefer it:
docker compose exec rcaudio rcaudio add https://ici.radio-canada.ca/ohdio/balados/13843/…
docker compose exec rcaudio rcaudio list
Set RCAUDIO_PUBLIC_URL if your podcast client is on another device. It is
baked into the enclosure URLs, so it has to be an address the client can
reach — localhost only works when the client is on this machine. Edit
compose.yaml:
environment:
RCAUDIO_PUBLIC_URL: "http://192.168.1.10:8477"
Docker notes
The SQLite cache lives in ./data, bind-mounted rather than kept in a named
volume, so a backup is cp -a data /somewhere and docker compose down -v
cannot take it with it. A bind mount keeps the host's ownership, so two things
have to line up or the container cannot write it:
user:must match whoever owns./data— the image otherwise runs as uid 10001. That is whatRCAUDIO_UID/RCAUDIO_GIDin.envare for.:Zon the mount relabels for SELinux — required on Fedora/RHEL, harmless elsewhere.
Either one alone still fails. If you get it wrong the startup error names both
fixes rather than saying unable to open database file. Create data/
yourself before the first up: Docker will otherwise create it owned by root.
docker compose logs -f # watch refreshes
docker compose pull && docker compose up -d # update to a newer package
docker compose restart # after changing compose.yaml
The workflow also tags :sha-<short> on every build and :1.2 / :1.2.3 for
a v1.2.3 git tag, so image: can be pinned instead of tracking :latest.
Who can reach the management page
The management routes mutate state and are not authenticated. Cross-origin form posts are rejected, so another site cannot drive them while you have the page open — but anyone who can reach the port can add and remove shows.
The default publishes to 127.0.0.1 only via Docker's port mapping on
localhost. If you expose it to your LAN so a phone can reach the feeds, put a
reverse proxy with a password in front of / and /shows*, or keep the port
firewalled and reach it over a VPN or SSH tunnel. /feed/*.xml has to stay open
for podcast clients regardless.
Running without Docker
python3 -m venv .venv
.venv/bin/pip install -e .
.venv/bin/rcaudio add https://ici.radio-canada.ca/ohdio/balados/13843/hors-des-ondes-avec-patrice-roy
.venv/bin/rcaudio serve
Commands
| Command | What it does |
|---|---|
rcaudio add <url|id>... |
Subscribe, fetch immediately, print the feed URL |
rcaudio remove <url|id>... |
Unsubscribe and drop the cached episodes |
rcaudio list |
Show subscriptions, episode counts, and feed URLs |
rcaudio refresh [show...] |
Refresh now, without waiting for the loop |
rcaudio serve |
Run the feed server and management UI |
Everything except serve is also available from the web page at /.
Configuration
All via environment variables:
| Variable | Default | Meaning |
|---|---|---|
RCAUDIO_PUBLIC_URL |
http://localhost:8477 |
The address your podcast client uses. It is baked into the enclosure URLs, so set it if the client is on another machine. |
RCAUDIO_DB |
~/.local/share/rcaudio/rcaudio.db |
SQLite cache location |
RCAUDIO_HOST / RCAUDIO_PORT |
127.0.0.1 / 8477 |
Listen address (the image sets host to 0.0.0.0) |
RCAUDIO_REFRESH_INTERVAL |
3600 |
Seconds between refreshes |
RCAUDIO_MAX_EPISODES |
0 (unlimited) |
Cap items per feed |
RCAUDIO_MEDIA_TTL |
21600 |
Re-resolve an audio URL older than this |
RCAUDIO_RESOLVE_BUDGET |
60 |
Max audio URLs resolved per refresh cycle |
Running bare-metal with the client on another device, bind wider too:
RCAUDIO_HOST=0.0.0.0 RCAUDIO_PUBLIC_URL=http://192.168.1.10:8477 rcaudio serve
Running under systemd (without Docker)
mkdir -p ~/.config/systemd/user
cp rcaudio.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now rcaudio
systemctl --user status rcaudio
Edit the Environment= lines in the unit first if you changed any settings.
loginctl enable-linger $USER keeps it running when you're not logged in.
The web interface
Served at / by the same process that serves the feeds:
- Add — paste a show URL or a bare numeric id
- Refresh — re-scrape one show, or all of them
- Remove — unsubscribe and drop the cached episodes
- Copy — the feed URL, for pasting into your podcast client
Scrapes run in the background, so the page never blocks on one. A show that fails to fetch stays listed with its error visible rather than disappearing silently. Server-rendered plain HTML — no build step, no external assets, and the only JavaScript is the copy button and the auto-refresh while a scrape is in flight.
How it works
Episode data. Every OHdio show page server-renders its view model into a
window._rcState_ script tag, including the full episode list with titles,
summaries, durations, publish dates, and mediaIds. rcaudio parses that
directly. Pages hold 50 episodes; ?pageNumber=N walks the rest and 404s past
the end. Shows with seasons render only the newest season by default, with the
others listed in filters — each is fetched separately, which is why a show like
Redoutables yields all 77 episodes rather than the 13 the page first shows.
Audio. services.radio-canada.ca/media/validation/v2/ with tech=progressive
turns a mediaId into a direct, unauthenticated, range-capable file (AAC in an
MP4 container — hence audio/mp4, not audio/mpeg). No login or token involved.
Enclosures are local redirects. Feed items point at /media/<mediaId>.m4a on
this server, which 302s to a freshly resolved URL. Audio bytes never pass through
here — the client downloads straight from the CDN — but URLs can't go stale, and
episodes become playable the moment they're scraped, before their byte size has
been probed.
Caching. lastBuildDate reflects the last real content change rather than
the current time, so an unchanged feed is byte-identical between refreshes and a
polling client gets a 304 with an empty body instead of a full transfer.
Rate limiting. The validation service allows roughly 30 requests/minute before
returning a bare 429 and refusing for the rest of the window. A shared token
bucket holds to 20/min, and each refresh resolves a bounded batch (newest
episodes first), leaving the rest for the next cycle. Episodes are published
with length="0" until their size is known.
Tests
.venv/bin/pip install pytest pytest-asyncio
.venv/bin/python -m pytest
The suite is offline — the scraper is driven through a mock transport shaped like real OHdio pages.
Notes
This is a personal-use interoperability tool: it fetches the same freely available files the OHdio web player does, and reformats the listing as RSS. Be considerate with refresh intervals.