Turn Youtube, SoundCloud, and other content into audio podcast episodes by pasting a URL
  • Go 90.7%
  • CSS 8.6%
  • Dockerfile 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
hijynx 8b31c4ea03
All checks were successful
build / docker (push) Successful in 3m4s
fix(server): force stylesheet revalidation with no-cache + content ETag (#19)
## 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]>
2026-10-02 13:15:43 -06:00
.forgejo/workflows build: weekly scheduled rebuild for yt-dlp updates (#16) 2026-09-29 16:38:16 -06:00
cmd/mediacast feat(build): stamp image with commit sha and build date (#15) 2026-09-25 16:31:10 -06:00
docs/adr docs: domain glossary, ADR-0001, mise tool pin 2026-09-06 15:56:48 -06:00
internal fix(server): force stylesheet revalidation with no-cache + content ETag (#19) 2026-10-02 13:15:43 -06:00
media feat: bundled default artwork + artwork in the UI (#3) 2026-09-06 19:17:34 -06:00
.dockerignore feat: single-image docker build with bind-mounted data dir 2026-09-06 15:27:27 -06:00
.gitignore feat: single-image docker build with bind-mounted data dir 2026-09-06 15:27:27 -06:00
AGENTS.md docs: require feature branches for all changes (#12) 2026-09-11 09:44:36 -06:00
CONTEXT.md docs: domain glossary, ADR-0001, mise tool pin 2026-09-06 15:56:48 -06:00
docker-compose.yml feat: rss feed endpoint 2026-09-06 15:42:13 -06:00
Dockerfile feat(build): stamp image with commit sha and build date (#15) 2026-09-25 16:31:10 -06:00
go.mod feat: env config and sqlite setup 2026-09-06 15:27:17 -06:00
go.sum feat: env config and sqlite setup 2026-09-06 15:27:17 -06:00
mise.toml fix(server): stack player dock so native volume slider renders (#17) 2026-10-02 11:55:39 -06:00
README.md feat(server): login page + session cookie auth (#13) 2026-09-22 14:02:31 -06:00

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):

  1. 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.)
  2. Open /, log in, paste a URL (or curl -u user:pass --data-urlencode "url=<media url>" /capture → 303).
  3. The Episode row flips queued → done; the mp3 lands in <data dir>/audio/.
  4. curl http://localhost:8080/feed.xml shows one <item> with an audio/mpeg enclosure; 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 (see docs/adr/0001-exec-yt-dlp-in-same-image.md for 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 left processing at startup are requeued and simply re-run.
  • Only done Jobs 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).