From 293a627358553c9f61802caf9d4bc76af9c2e239 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 21 Sep 2026 06:50:42 +0000 Subject: podkit: playlist URL to private podcast feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One Go binary with fetch / tag / feed / serve / all. The stages run separately on purpose: the download is the slow part and should not be repeated while adjusting titles or the base URL. Built from a shell + Python + Go sketch. The pieces that needed fixing on the way, all of which produce a feed that loads wrong or not at all: - Enclosure URLs were unescaped. Track titles routinely contain spaces and ampersands, which make both the URL and the XML invalid — the likeliest way for a feed to fail to load entirely. Only the final path segment is escaped; escaping the base would mangle scheme and host. - Ordering came from filename sort, which is correct only while zero-padding is applied consistently. One unpadded file puts lecture 10 after lecture 1. Order now comes from the parsed track number. - No pubDate and no itunes:type. Podcast clients default to newest-first, which starts a lecture course at the final lecture. Items now carry distinct ascending dates and the feed is marked serial. - Missing channel description and link, which several clients reject outright. - A struct field with no xml tag emitted a stray element. - guid had no isPermaLink="false", so a reader may treat it as a fetchable URL. - Errors from ReadDir, Info and Marshal were discarded, so a missing directory produced an empty feed that looked like a working setup with nothing in it. - os.Args indexed without a length check. Beyond the sketch: a download archive so an interrupted series resumes, an up-front check for yt-dlp and ffmpeg before the long download rather than a post-processing failure after the bytes are on disk, non-audio files excluded by extension so cover art and .part files cannot become episodes, and a serve command with token gating that 404s rather than 401s. Found by the first live run against a LibriVox collection: sources whose own titles begin with a track number came out as "01 - 01 - April.mp3", because the output template prepends the playlist index regardless. Collapsed, but only when the two numbers agree — "01 - 1964 - Lecture" is a year and "05 - 12 Angry Men" is a title, and neither is a repeat. 47 tests. Verified end to end against public-domain audio: downloaded, converted, tagged, served, and the delivered file is a playable 128kbps MP3 with correct ID3 title/artist/album/track frames. --- README.md | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 README.md (limited to 'README.md') diff --git a/README.md b/README.md new file mode 100644 index 0000000..6157744 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# 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 | +| `podkit tag` | Write ID3 tags derived from filenames | +| `podkit feed` | Generate `feed.xml` | +| `podkit serve` | Host the directory and feed over HTTP | +| `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. + +## 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. + +## "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. + +## 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). -- cgit v1.2.3