A solution for re-publishing a podcast's real RSS feed as a new private feed. It can start releasing from any episode, at any cadence, including mirroring the original release cadence.
If a show has two feeds — the regular one and a premium/bonus one — you can meld them into a single delayed feed. Each bonus episode comes out the same distance after its free counterpart as it did originally: an hour stays an hour, a day stays a day, whatever cadence you're replaying the free feed at.
This is designed only to be run in a homelab or similar. It is single-user, with no support for separate 'accounts'. Audio is never re-hosted — enclosure URLs (and the publisher's analytics prefix) pass through untouched, so your listens still count. See docs/IMPLEMENTATION.md for the full (fully-Claude) design write-up.
I'm a bit nostalgic. There are some podcasts I started listening to years ago (2017~), but not at the start (2008~). At a certain point, the podcast changes, maybe some stalwarts rotate out of the main line-up... I just want to go back, to the beginning.
I'm also someone that has no self-restraint. I'll mainline ten of the same podcast in a day, then forget all about listening to no.11. Podcasts apps do not help this: It's hard to get them to cooperate in a way which surfaces your next episode in the 'Latest episodes' list, because while it may be up next for you, it was actually released 15 years ago.
PodcastDelay aims to solve that, by creating personalised feeds.
cp .env.example .env # then set PODCASTDELAY_BASE_URL, PODCASTDELAY_ADMIN_USER and PODCASTDELAY_ADMIN_PASSWORD
docker compose up -d
Then open http://localhost:8080/admin (basic auth: the user/password you configured) and add a feed.
Required environment variables — the container refuses to start without them:
| Variable | Notes |
|---|---|
PODCASTDELAY_BASE_URL | Public URL of this instance, e.g. https://podcasts.example.com. |
PODCASTDELAY_ADMIN_USER | Admin basic-auth username. |
PODCASTDELAY_ADMIN_PASSWORD (or _FILE) | Admin basic-auth password, or a path to a file containing it. |
See docs/IMPLEMENTATION.md §9.2 for the full list of variables and defaults.
go build ./cmd/podcastdelay
PODCASTDELAY_DATA_DIR=./data \
PODCASTDELAY_BASE_URL=http://localhost:8080 \
PODCASTDELAY_ADMIN_USER=admin \
PODCASTDELAY_ADMIN_PASSWORD=changeme \
./podcastdelay serve
Add a feed without opening the admin UI:
./podcastdelay add https://feeds.example.com/show.xml \
--every 7d --start tomorrow --seed 2
# ...with the show's premium feed melded in from the start
./podcastdelay add https://feeds.example.com/show.xml \
--premium https://feeds.example.com/show-premium.xml --every 7d
# ...or melded into a show you already have (by feed id or token)
./podcastdelay meld 1 https://feeds.example.com/show-premium.xml
./podcastdelay meld 1 --remove
Your podcast app needs to reach the feed from a phone on mobile data. Cloudflare Tunnel is the lowest-effort path for a box behind a home router (no port forwarding, free TLS). See docs §9.4 for alternatives.
The SQLite file at $PODCASTDELAY_DATA_DIR/podcastdelay.db is the entire state. Back it up with litestream, a daily sqlite3 .backup, or a volume snapshot — see docs §9.6.
go test ./... # unit + golden-file tests
UPDATE_GOLDEN=1 go test ./internal/feed/... -run TestRender_Golden
docker buildx build --platform linux/amd64,linux/arm64 -t podcastdelay .
internal/schedule is the pure release-time engine (no I/O) — start there if
you're trying to understand the seeding/locking/rescheduling rules.
The admin UI is server-rendered Go templates plus vendored Pico CSS
and htmx (internal/web/templates, internal/web/static); all of it is
embedded in the binary. Set PODCASTDELAY_DEV_WEB_DIR to a directory containing templates/
and static/ and the server reads them from disk on every request, so a browser refresh
picks up edits. With Compose, put this in your gitignored compose.override.yaml:
services:
podcastdelay:
build: .
environment:
PODCASTDELAY_DEV_WEB_DIR: /webdev
volumes:
- ./internal/web:/webdev:ro
Go changes still need docker compose up -d --build. Don't set the variable in production.
A solution for re-publishing a podcast's real RSS feed as a new private feed. It can start releasing from any episode, at any cadence, including mirroring the original release cadence.
If a show has two feeds — the regular one and a premium/bonus one — you can meld them into a single delayed feed. Each bonus episode comes out the same distance after its free counterpart as it did originally: an hour stays an hour, a day stays a day, whatever cadence you're replaying the free feed at.
This is designed only to be run in a homelab or similar. It is single-user, with no support for separate 'accounts'. Audio is never re-hosted — enclosure URLs (and the publisher's analytics prefix) pass through untouched, so your listens still count. See docs/IMPLEMENTATION.md for the full (fully-Claude) design write-up.
I'm a bit nostalgic. There are some podcasts I started listening to years ago (2017~), but not at the start (2008~). At a certain point, the podcast changes, maybe some stalwarts rotate out of the main line-up... I just want to go back, to the beginning.
I'm also someone that has no self-restraint. I'll mainline ten of the same podcast in a day, then forget all about listening to no.11. Podcasts apps do not help this: It's hard to get them to cooperate in a way which surfaces your next episode in the 'Latest episodes' list, because while it may be up next for you, it was actually released 15 years ago.
PodcastDelay aims to solve that, by creating personalised feeds.
cp .env.example .env # then set PODCASTDELAY_BASE_URL, PODCASTDELAY_ADMIN_USER and PODCASTDELAY_ADMIN_PASSWORD
docker compose up -d
Then open http://localhost:8080/admin (basic auth: the user/password you configured) and add a feed.
Required environment variables — the container refuses to start without them:
| Variable | Notes |
|---|---|
PODCASTDELAY_BASE_URL | Public URL of this instance, e.g. https://podcasts.example.com. |
PODCASTDELAY_ADMIN_USER | Admin basic-auth username. |
PODCASTDELAY_ADMIN_PASSWORD (or _FILE) | Admin basic-auth password, or a path to a file containing it. |
See docs/IMPLEMENTATION.md §9.2 for the full list of variables and defaults.
go build ./cmd/podcastdelay
PODCASTDELAY_DATA_DIR=./data \
PODCASTDELAY_BASE_URL=http://localhost:8080 \
PODCASTDELAY_ADMIN_USER=admin \
PODCASTDELAY_ADMIN_PASSWORD=changeme \
./podcastdelay serve
Add a feed without opening the admin UI:
./podcastdelay add https://feeds.example.com/show.xml \
--every 7d --start tomorrow --seed 2
# ...with the show's premium feed melded in from the start
./podcastdelay add https://feeds.example.com/show.xml \
--premium https://feeds.example.com/show-premium.xml --every 7d
# ...or melded into a show you already have (by feed id or token)
./podcastdelay meld 1 https://feeds.example.com/show-premium.xml
./podcastdelay meld 1 --remove
Your podcast app needs to reach the feed from a phone on mobile data. Cloudflare Tunnel is the lowest-effort path for a box behind a home router (no port forwarding, free TLS). See docs §9.4 for alternatives.
The SQLite file at $PODCASTDELAY_DATA_DIR/podcastdelay.db is the entire state. Back it up with litestream, a daily sqlite3 .backup, or a volume snapshot — see docs §9.6.
go test ./... # unit + golden-file tests
UPDATE_GOLDEN=1 go test ./internal/feed/... -run TestRender_Golden
docker buildx build --platform linux/amd64,linux/arm64 -t podcastdelay .
internal/schedule is the pure release-time engine (no I/O) — start there if
you're trying to understand the seeding/locking/rescheduling rules.
The admin UI is server-rendered Go templates plus vendored Pico CSS
and htmx (internal/web/templates, internal/web/static); all of it is
embedded in the binary. Set PODCASTDELAY_DEV_WEB_DIR to a directory containing templates/
and static/ and the server reads them from disk on every request, so a browser refresh
picks up edits. With Compose, put this in your gitignored compose.override.yaml:
services:
podcastdelay:
build: .
environment:
PODCASTDELAY_DEV_WEB_DIR: /webdev
volumes:
- ./internal/web:/webdev:ro
Go changes still need docker compose up -d --build. Don't set the variable in production.