1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
|
# 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 "<playlist url>" \
--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 <url>` | Download and extract audio via yt-dlp |
| `podkit get <url>` | 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 <url>` | 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 "<rss or audio url>" --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).
|