A small, self-hosted archiver for cycling race videos. It browses a source site's uploads, lets you subscribe to specific races/categories, and automatically downloads new videos as they're published — via yt-dlp — into a folder structure on disk you control.
Runs as a single Docker container with a small FastAPI + HTMX web UI for tracking discovery and download status.
- Latest Uploads feed and a Races & Categories browser, both scraped live from the source site
- Subscribe to a category to auto-download new uploads going forward — browsing never triggers a download on its own, only subscribing or an explicit click does
- Resumable downloads — a stuck/stalled download is detected and killed automatically (independent OS-level watchdog, not just yt-dlp's own timeout) and retried with exponential backoff
- Cancel / Resume / Delete for an in-progress download, and Redownload for anything already saved
- Stream from source for videos that aren't downloaded yet — plays straight from the source site's CDN (or its YouTube embed) without saving anything, and remembers the playback position just like a downloaded file
- Theater mode on the video page — a bigger player and less surrounding clutter, without going into browser fullscreen
- Push notifications (via Pushover) when a download starts, finishes, or gives up after exhausting its retries, linking straight back to the video
- Archive view of everything downloaded, with both download and upload dates
- Runtime-configurable Settings page (poll interval, retry limits, concurrency, source host, notifications) — no restart needed
- Survives container restarts cleanly: an interrupted download is requeued automatically on startup instead of silently stalling forever
curl -O https://raw.githubusercontent.com/inket/archivelo/main/docker-compose.yml
docker compose pull
docker compose up -dThe app will be available at http://<host>:8000. Two volumes get created next to docker-compose.yml: ./config (database + log — small, worth backing up) and ./downloads (the actual video files — large, point it at a different drive/mount if you want by changing the host-side path in docker-compose.yml).
To update later: docker compose pull && docker compose up -d. docker-compose.yml pulls the latest tag, which always points at the most recently published release — not every commit. Pin image: to a specific version instead (e.g. ghcr.io/inket/archivelo:0.1.0) if you'd rather control updates manually.
All of these are set as environment: entries in docker-compose.yml:
| Variable | Default | Purpose |
|---|---|---|
SOURCE_BASE_URL |
(the source site) | Host to scrape, in case it ever moves |
POLL_INTERVAL_SECONDS |
900 |
How often subscribed categories are checked for new uploads |
RETRY_INTERVAL_SECONDS |
300 |
How often a failed discovery check is retried |
MAX_RETRIES |
5 |
Auto-retry attempts for a failed download before giving up |
MAX_CONCURRENT_DOWNLOADS |
1 |
How many downloads run at once |
DISCOVERY_PAGE_DEPTH |
3 |
How many listing pages deep to check per category |
PUID / PGID |
0 / 0 (root) |
Set to your host user's uid/gid so files under ./config and ./downloads aren't owned by root |
BASE_PATH |
(empty) | Set if serving behind a reverse proxy at a subpath, e.g. /archivelo for https://example.org/archivelo — see below |
Most of these (poll interval, retry limits, concurrency, source host) can also be changed later from the Settings page in the UI without restarting the container — the env var only supplies the initial value.
If you're putting Archivelo behind a reverse proxy at https://example.org/archivelo rather than its own domain/subdomain, set BASE_PATH=/archivelo. The app owns that prefix internally (the same "URL Base" pattern apps like Sonarr/Radarr use) — a plain forward is enough, no rewrite/strip rule needed on the proxy side:
location /archivelo/ {
proxy_pass http://archivelo:8000;
}If you're using a subdomain instead (https://archivelo.example.org), leave BASE_PATH empty — no path rewriting is involved.
Requires Python 3.12+ and ffmpeg on PATH, plus deno for YouTube-hosted videos (yt-dlp uses it to solve YouTube's player challenges).
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.local.example .env.local # or create your own; see app/config.py for all variables
set -a && source .env.local && set +a
.venv/bin/uvicorn app.main:app --reload- Discovery and downloads run as background threads inside the same process — no separate worker/queue service.
- The site's markup is scraped with a mix of
BeautifulSoupand targeted regexes (some of its HTML is malformed enough that a strict tree parse misses content). - Downloads shell out to
yt-dlpas a subprocess rather than using its Python API, with an independent watchdog thread that kills and retries a download if it stalls — this proved more reliable than relying on yt-dlp's own internal timeouts. - yt-dlp upgrades itself (
pip install -U) right before each download, since YouTube changes often enough to break older versions. In the Docker image it lives in its own venv (/opt/yt-dlp) so the upgrade never touches the app's dependencies; if the upgrade fails, the download proceeds on the installed version. - Publishing an image to
ghcr.io/inket/archivelois decoupled from pushing tomain— see Releasing below. Deploying is just pulling; nothing gets built on the host.
Images are built by .github/workflows/docker-publish.yml, triggered only when a GitHub Release is published — plain pushes to main don't build or publish anything. Versions follow semver; while the project's still early, that means 0.MINOR.PATCH (bump MINOR for features, PATCH for fixes), moving to 1.0.0 once it feels stable.
To cut a release:
git tag v0.1.0
git push origin v0.1.0
gh release create v0.1.0 --generate-notesThat publishes ghcr.io/inket/archivelo:0.1.0 and moves the latest tag to it.
MIT — see LICENSE.