- Go 90.7%
- CSS 8.6%
- Dockerfile 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
build / docker (push) Successful in 3m4s
## Summary - `/style.css` now sends `Cache-Control: no-cache` plus a content-hash `ETag` (first 16 hex of sha256), asserted in `TestStyleSheetServesWithCorrectType`. ## Why Proxies/tunnels (e.g. Cloudflare) were serving stale dock CSS after deploys — the handler previously sent no cache directives, so old layouts survived restarts. no-cache forces revalidation on every load; the ETag keeps unchanged reloads cheap. Co-authored-by: w33ble <[email protected]> Reviewed-on: #19 Reviewed-by: w33ble <[email protected]> |
||
| .forgejo/workflows | ||
| cmd/mediacast | ||
| docs/adr | ||
| internal | ||
| media | ||
| .dockerignore | ||
| .gitignore | ||
| AGENTS.md | ||
| CONTEXT.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| mise.toml | ||
| README.md | ||
Mediacast
Mediacast is a single-user service that turns pasted media URLs into podcast episodes: Capture a Source URL, its audio is extracted in the background, and it appears as an Episode on your one global podcast Feed.
Domain vocabulary (see CONTEXT.md):
- Source — the original URL you paste (YouTube, SoundCloud, …).
- Episode — one processed Source: extracted audio plus metadata, one RSS item on the Feed.
- Feed — the single global podcast feed (
/feed.xml). There is exactly one. - Job — the queued unit of work turning a Source into an Episode:
queued → processing → done | failed. A failed Job produces no Episode. - Capture — submitting a Source URL. Accepted synchronously; the download runs async, so once captured you are done.
- Normalize — light URL canonicalization (strip tracking params,
youtu.be/<id>→youtube.com/watch?v=<id>) for best-effort dedupe: re-capturing the same Source reuses the existing Job.
Quickstart
Prerequisites: Docker with the Compose plugin.
export MEDIACAST_USER=you
export MEDIACAST_PASSWORD='a-long-secret'
docker compose up --build -d
Open http://your-host:8080/ and log in with those credentials on the
login page. The session cookie keeps you logged in for about 30 days;
the Log out link in the header clears it. Paste a media URL, and
watch it go queued → done. Point a podcast catcher at
http://your-host:8080/feed.xml.
The app is HTTP-only; terminate TLS in a reverse proxy in front of it
(VPS + docker-compose is the intended deployment). All state lives in
the one bind-mounted data dir (./data → /data): the SQLite db plus
extracted mp3s.
Configuration
Every setting is an environment variable:
| Variable | Default | Meaning |
|---|---|---|
MEDIACAST_USER |
(required) | Basic-auth username (whole UI/API). |
MEDIACAST_PASSWORD |
(required) | Basic-auth password. |
MEDIACAST_DATA_DIR |
./data (/data in compose) |
Dir holding mediacast.db + audio/. |
MEDIACAST_ADDR |
:8080 |
Listen address. |
MEDIACAST_FEED_TITLE |
Mediacast |
Feed channel title. |
MEDIACAST_FEED_AUTHOR |
Mediacast |
Feed itunes:author. |
MEDIACAST_BASE_URL |
(unset) | Absolute base for enclosure/artwork URLs, e.g. https://feeds.example.com. Set this behind a reverse proxy, otherwise the feed derives http:// + request Host per request. |
MEDIACAST_FEED_AUTH |
open |
open = /feed.xml, /audio/*, /artwork.png are public; auth = same basic-auth credentials protect them too. UI/API always need auth either way. |
MEDIACAST_ARTWORK |
(unset) | Server-side filesystem path (e.g. /art/art.png inside the container) to a png/jpg image. The app serves that file over HTTP at /artwork.png and uses it as the channel-level itunes:image. Not a URL — any path readable by the container works, /data included, though keeping artwork out of the data dir is recommended (that volume is for runtime state). Unset → bundled default artwork from media/mediacast_512.png (512×512 PNG); set but missing → /artwork.png returns 404. |
MEDIACAST_YTDLP_BIN |
yt-dlp |
yt-dlp executable path. |
MEDIACAST_YTDLP_TIMEOUT |
30m |
Per-extraction cap (Go duration string). |
MEDIACAST_RUNNER |
ytdlp |
ytdlp (production) or fake (local dev: synthetic mp3, no network). |
docker-compose example
docker-compose.yml (single service, one bind-mounted data dir):
services:
app:
build: .
ports:
- "8080:8080"
environment:
MEDIACAST_USER: ${MEDIACAST_USER:?MEDIACAST_USER is required}
MEDIACAST_PASSWORD: ${MEDIACAST_PASSWORD:?MEDIACAST_PASSWORD is required}
MEDIACAST_DATA_DIR: /data
MEDIACAST_ADDR: :8080
MEDIACAST_FEED_TITLE: ${MEDIACAST_FEED_TITLE:-Mediacast}
MEDIACAST_FEED_AUTHOR: ${MEDIACAST_FEED_AUTHOR:-Mediacast}
MEDIACAST_BASE_URL: ${MEDIACAST_BASE_URL:-}
MEDIACAST_FEED_AUTH: ${MEDIACAST_FEED_AUTH:-open}
volumes:
- ./data:/data
restart: unless-stopped
Back up ./data and you have backed up everything (db + audio).
To serve a custom artwork file, bind-mount a file from the host into the
container and point MEDIACAST_ARTWORK at the container path — e.g. volumes
- ./art.png:/art/art.png plus MEDIACAST_ARTWORK: /art/art.png. The path is
read by the app process, not downloaded from a URL; anything the container can
read works, so a file placed under the /data volume is fine too.
Subscribing
Point any podcast catcher at:
http(s)://<host>:8080/feed.xml
- Default (
MEDIACAST_FEED_AUTH=open): no credentials needed for the feed. The UI/API (/,/capture,/healthz) still require login. - With
MEDIACAST_FEED_AUTH=auth, subscribe with credentials in the URL:
http://user:pass@<host>:8080/feed.xml
The flag covers the whole catcher surface — feed, episode audio
(/audio/*.mp3), and artwork — so enclosures keep working with the
same credentials.
Smoke check
After docker compose up --build -d (or a local binary):
curl -u user:pass http://localhost:8080/healthz→{"status":"ok"}(HTTP basic is still accepted everywhere, so curl and scripts can keep using-u user:pass; browsers use the login page instead.)- Open
/, log in, paste a URL (orcurl -u user:pass --data-urlencode "url=<media url>" /capture→ 303). - The Episode row flips
queued → done; the mp3 lands in<data dir>/audio/. curl http://localhost:8080/feed.xmlshows one<item>with anaudio/mpegenclosure; subscribe a catcher to that URL.
How it works
- One Docker image (alpine:edge): static Go binary + uv-managed
yt-dlp (
yt-dlp[default,curl-cffi]) + ffmpeg. yt-dlp is exec'd in-image (seedocs/adr/0001-exec-yt-dlp-in-same-image.mdfor why there is no sidecar) with mp3 extraction (-x --audio-format mp3), info-json metadata (title, duration, thumbnail, uploader, description), and ID3 tags written onto the mp3 itself (--embed-metadata: title, description, canonical source URL, artist, date, genre). - SQLite + audio files share one data dir; schema migrates on startup
(
schema_version). Jobs leftprocessingat startup are requeued and simply re-run. - Only
doneJobs appear in/feed.xml(newest first). Missing titles fall back to the Source URL slug; missing art falls back to the channel-level placeholder instead of per-item art. The Source description ships as the item<description>+<itunes:summary>(omitted when the platform provides none).