Outil pour exposer les podcasts de Radio-Canada en fils RSS compatible avec les applications de podcast.
  • Python 98.5%
  • Dockerfile 1.5%
Find a file
scarpentier b9bae78e05
Some checks failed
publish / publish (push) Failing after 7s
Merge pull request 'action' (#2) from action into main
Reviewed-on: #2
2026-08-27 21:29:48 -04:00
.forgejo/workflows fix 2026-08-27 21:29:08 -04:00
rcaudio fix 2026-08-27 21:29:08 -04:00
rcaudio.egg-info First commit 2026-08-25 22:46:08 -04:00
tests fix 2026-08-27 21:29:08 -04:00
.dockerignore action 2026-08-27 21:10:35 -04:00
.gitignore action 2026-08-27 21:10:35 -04:00
compose.yaml action 2026-08-27 21:10:35 -04:00
Dockerfile action 2026-08-27 21:10:35 -04:00
env.example action 2026-08-27 21:10:35 -04:00
pyproject.toml First commit 2026-08-25 22:46:08 -04:00
pytest.ini First commit 2026-08-25 22:46:08 -04:00
rcaudio.service First commit 2026-08-25 22:46:08 -04:00
README.md action 2026-08-27 21:10:35 -04:00

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 what RCAUDIO_UID / RCAUDIO_GID in .env are for.
  • :Z on 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.