← all projects

yt-listen-later

repo

yt-listen-later

Turn a YouTube playlist into a podcast RSS feed you can subscribe to in Overcast (or any other podcast app). Add a video to the playlist on your phone, and the audio shows up as a new episode.

A single uv script does the whole job: it reads the playlist with yt-dlp, extracts audio with ffmpeg, writes an iTunes-flavoured feed.xml, and serves both over HTTP with byte-range support so seeking works.

To run it on a server, go straight to Deploying with Docker — that needs nothing on the host but Docker itself.

Running it locally

Needs uv (which fetches Python and the script's dependencies itself) and ffmpeg on PATH.

cp .env.example .env
$EDITOR .env          # set YOUTUBE_PLAYLIST_URL and BASE_URL

Or let devbox provide both dependencies, so neither lands on your system:

devbox shell           # uv + ffmpeg on PATH
devbox run sync        # also: feed, serve, run, test

Usage

uv run ./yt_listen_later.py sync     # download new audio, rebuild feed.xml
uv run ./yt_listen_later.py serve    # serve public/ over HTTP
uv run ./yt_listen_later.py run      # sync now, then serve + re-sync on a timer
uv run ./yt_listen_later.py feed     # rebuild feed.xml only, no downloads

The script is executable, so ./yt_listen_later.py run works too.

Then subscribe in Overcast: + → Add URL and paste $BASE_URL/feed.xml.

Making it reachable from Overcast

Overcast fetches the feed from its own servers, so localhost won't do — BASE_URL has to be publicly reachable and must match how the feed is served. Any of these work:

Set BASE_URL to the resulting HTTPS URL and re-run sync (or feed) so the enclosure URLs are regenerated.

Anyone with the URL can read the feed and the audio — there's no auth. Keep the URL private, or put basic auth on the reverse proxy.

Deploying with Docker

This is the supported way to run it on a server. The image carries ffmpeg and the Python dependencies, so the VPS needs nothing but Docker.

git clone https://github.com/svandragt/yt-listen-later && cd yt-listen-later
cp .env.example .env
$EDITOR .env                          # playlist + BASE_URL

mkdir -p data && sudo chown -R 1000:1000 data   # container runs as uid 1000

docker compose up -d --build
docker compose logs -f

That runs run: one sync on startup, then serving while re-syncing every REFRESH_MINUTES. A single foreground process, restarted by Docker if it dies.

Add HTTPS — which Overcast requires — with the bundled Caddy service:

$EDITOR deploy/Caddyfile               # replace listen.example.com
docker compose --profile tls up -d

Caddy fetches a certificate on first request and proxies to the app container. Set BASE_URL to the same hostname, then subscribe in Overcast to $BASE_URL/feed.xml.

Day-to-day

docker compose exec yt-listen-later app-python /app/yt_listen_later.py sync   # sync now, don't wait for the timer
docker compose exec yt-listen-later app-python /app/yt_listen_later.py feed   # rebuild feed.xml after editing .env
docker compose run --rm yt-listen-later app-python /app/test_yt_listen_later.py
docker compose up -d --build               # upgrade after a git pull
docker compose logs -f yt-listen-later

Everything mutable lives in ./data — audio, feed.xml, and the state file — so that's the only thing to back up, and destroying the container loses nothing.

Notes for a small VPS

Disk is usually the binding constraint, and audio accumulates quietly:

Without compose

docker build -t yt-listen-later .
docker run -d --name yt-listen-later --restart unless-stopped \
  --env-file .env -e PUBLIC_DIR=/data/public \
  -v "$PWD/data:/data" -p 127.0.0.1:8000:8000 \
  yt-listen-later

Pass a subcommand to override the default: docker run --rm ... yt-listen-later sync.

Keeping it fresh

run re-syncs every REFRESH_MINUTES in-process, which is convenient on a laptop. On a server prefer the systemd timer above, or plain cron:

*/30 * * * * cd /srv/yt-listen-later && /usr/local/bin/uv run ./yt_listen_later.py sync >> sync.log 2>&1

How it works

Tests

uv run ./test_yt_listen_later.py covers the sync logic — incremental downloads, resume after a failed video, pruning, MAX_EPISODES, and feed ordering — with the network stubbed out, so it needs neither YouTube nor ffmpeg.

Configuration

Everything lives in .env — see .env.example for the full, commented list.