# podkit Turns a playlist or video URL into a private podcast feed you can subscribe to from any podcast app. One Go binary; the fetch step shells out to `yt-dlp`. ``` podkit all "" \ --dir ./audio \ --title "Character of Physical Law" \ --artist "Richard Feynman" \ --base-url "https://your-host/audio/" podkit serve --dir ./audio --addr :8080 --new-token ``` Point your podcast app at the feed URL that `serve` prints. That's it. ## Install ``` go build -o podkit . pip install --user yt-dlp # needed only for `podkit fetch` ``` `ffmpeg` must also be on PATH — yt-dlp uses it to extract audio. `podkit` checks for both **before** starting a long download rather than failing during post-processing, after the bytes are already on disk. ## Commands | Command | What it does | | --- | --- | | `podkit fetch ` | Download and extract audio via yt-dlp | | `podkit get ` | Download a direct audio URL, or every enclosure in an RSS feed — no yt-dlp, no ffmpeg | | `podkit tag` | Write ID3 tags derived from filenames | | `podkit feed` | Generate `feed.xml` | | `podkit serve` | Host the directory and feed over HTTP | | `podkit push` | Upload a local library to a remote `podkit serve --ingest` | | `podkit all ` | fetch → tag → feed | Each stage runs independently on purpose: the download is the slow part and shouldn't be repeated while you're adjusting titles or the base URL. ## Downloading somewhere other than where you host The download and the hosting do not have to happen on the same machine, and usually shouldn't. A server on a datacentre IP is treated very differently from a home connection by the larger video sites — YouTube in particular will refuse a datacentre request outright with *"Sign in to confirm you're not a bot"* while the identical command works fine from a laptop. So run the download where you actually are, and push the result to the host: ```bash # on your own machine podkit get "" --dir ./audio podkit tag --dir ./audio --title "…" --artist "…" podkit push --dir ./audio --server https://your-host/podkit --token "$TOKEN" ``` ```bash # on the server, once podkit serve --dir /srv/podkit/audio --addr 127.0.0.1:8099 \ --ingest --token "$TOKEN" \ --title "…" --artist "…" \ --base-url "https://your-host/podkit/audio/" ``` `push` asks the server what it already has and uploads only the difference, so re-running after adding three episodes to a 200-episode series sends three files. `--ingest` regenerates the feed after each upload. This also means the server never needs credentials for, or a working route to, wherever the audio came from. **`--ingest` is off by default.** A host that accepts writes is a different security proposition from one that only reads, and it refuses to start without a token. ### Behind a reverse proxy `serve` expects to be mounted with the path prefix stripped, which is what doot's `NewServiceProxy` already does. Give `--base-url` the public path and point `push --server` at the same place: ``` --base-url https://doot.terst.org/podkit/audio/ --server https://doot.terst.org/podkit ``` ## Notes on the things that usually go wrong These are the parts a hand-rolled version of this pipeline tends to get wrong, and what `podkit` does instead. **Filenames in URLs.** Lecture titles contain spaces and ampersands. Pasted into an enclosure URL unescaped they produce both an invalid URL and invalid XML, and the feed won't load at all. `podkit` escapes the final path segment only — escaping the whole base would mangle the scheme and host. **Episode order.** `01, 02, … 10` sorts correctly as text only while the zero-padding is applied consistently; one unpadded file and lecture 10 lands after lecture 1. Order comes from the parsed track number, not the filename. Untracked files sort last rather than jumping to the front. **Play direction.** Podcasts default to newest-first, which starts a lecture course at the final lecture. The feed sets `itunes:type=serial` and gives every item a distinct ascending `pubDate`, so clients present it in order either way. **`guid`.** Marked `isPermaLink="false"`. Without that, a reader is entitled to treat the value as a URL and fetch it; these identifiers are filenames. **Required channel elements.** `description` and `link` are mandatory in practice — several clients silently reject a feed lacking them. **Non-audio files.** Cover art and the `.part` files an interrupted download leaves behind are skipped by extension, so they can't become episodes. **Resumability.** `fetch` keeps a download archive (`.podkit-archive`), so re-running skips what's already there. Tagging is idempotent, and re-tagging preserves artwork yt-dlp embedded rather than replacing the tag wholesale. **ID3 only on MP3.** ID3v2 is an MP3 convention; writing it into `.opus` or `.m4a` produces something no player reads, so other formats are left alone. **The token has to be in the enclosure URLs.** A podcast client fetches the feed, then fetches each episode as a plain GET carrying nothing from the first request. If the server wants a token and the enclosure URLs don't have one, the feed loads, lists every episode, and not one of them will ever download. `--token` on `feed` puts it in the URLs; `serve --ingest` does it for you. **Identifying itself.** Go's default user-agent is rejected outright by several podcast CDNs — libsyn returns 403 for it. `get` sends a real one. ## "Private" `--token` means unlisted-and-unguessable, not authenticated. Podcast clients can't do interactive login, so a shared secret in the URL is the only mechanism that actually works with them — anyone holding the URL has access. Rejections return 404 rather than 401, so a prober learns nothing. For anything beyond a home network, put it behind a reverse proxy with TLS. `serve` speaks plain HTTP and is not intended to face the internet directly. ## Sourcing `podkit` takes a URL you supply and does nothing to find content. Whether you may download a given item is between you and whoever published it — check the source's terms. There is deliberately no cookie-jar or sign-in option. Feeding a site your logged-in session to get past a check that exists to stop automated downloading is a different act from downloading, and this tool doesn't do it. `get` covers the sources that don't ask: RSS feeds, archive.org, direct links. ## Tests ``` go test ./... ``` Covers the feed generator (URL escaping, ordering, XML well-formedness, non-audio filtering), the tagger (round-trips, `n/total` track frames, idempotency) and the server (token gating, path traversal, that the escaped URLs the generator emits actually resolve against the server that serves them).