diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-21 06:50:42 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-21 06:50:42 +0000 |
| commit | 293a627358553c9f61802caf9d4bc76af9c2e239 (patch) | |
| tree | e4769ac949659953bd5e09cebea9d0697e82548f /internal/feed | |
podkit: playlist URL to private podcast feed
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 <EnclosureURL> 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.
Diffstat (limited to 'internal/feed')
| -rw-r--r-- | internal/feed/feed.go | 271 | ||||
| -rw-r--r-- | internal/feed/feed_test.go | 343 |
2 files changed, 614 insertions, 0 deletions
diff --git a/internal/feed/feed.go b/internal/feed/feed.go new file mode 100644 index 0000000..02a016e --- /dev/null +++ b/internal/feed/feed.go @@ -0,0 +1,271 @@ +// Package feed builds a podcast RSS document from a directory of audio files. +package feed + +import ( + "encoding/xml" + "fmt" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +// Options configure a generated feed. +type Options struct { + Dir string + BaseURL string + Title string + Description string + Author string + Link string + Language string + // Epoch is the publication date given to the first track. Later tracks get + // successively later dates so that episode order is unambiguous; see + // pubDate handling in Build. + Epoch time.Time +} + +// Item is one episode. +type Item struct { + XMLName xml.Name `xml:"item"` + Title string `xml:"title"` + Description string `xml:"description,omitempty"` + GUID GUID `xml:"guid"` + PubDate string `xml:"pubDate"` + Enclosure Enclosure + Episode int `xml:"itunes:episode,omitempty"` + ITunesTitle string `xml:"itunes:title,omitempty"` +} + +// GUID is an episode's stable identifier. +// +// isPermaLink="false" matters: without it a reader is entitled to treat the +// value as a URL and fetch it. These identifiers are derived from filenames, +// not addresses. +type GUID struct { + XMLName xml.Name `xml:"guid"` + IsPermaLink bool `xml:"isPermaLink,attr"` + Value string `xml:",chardata"` +} + +// Enclosure is the audio file itself. +type Enclosure struct { + XMLName xml.Name `xml:"enclosure"` + URL string `xml:"url,attr"` + Type string `xml:"type,attr"` + Length int64 `xml:"length,attr"` +} + +// Channel is the feed body. +type Channel struct { + XMLName xml.Name `xml:"channel"` + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Language string `xml:"language,omitempty"` + Author string `xml:"itunes:author,omitempty"` + // serial tells a podcast client this is a course to be played from the + // beginning, not a stream of news where the newest item comes first. For a + // lecture series that distinction is the difference between starting at + // lecture one and starting at the end. + ITunesType string `xml:"itunes:type,omitempty"` + Items []Item +} + +// RSS is the document root. +type RSS struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + ITunesNS string `xml:"xmlns:itunes,attr"` + ContentNS string `xml:"xmlns:content,attr"` + Channel Channel +} + +// TagReader reads metadata from an audio file. Injected so the feed can be +// tested without real ID3 frames. +type TagReader func(path string) (title string, track int, ok bool) + +var trackPrefix = regexp.MustCompile(`^(\d+)\s*[-–—.]\s*(.+)$`) + +// audioTypes maps an extension to its MIME type. Anything not listed is not +// treated as audio, so cover art and .part files left behind by an interrupted +// download cannot end up as episodes. +var audioTypes = map[string]string{ + ".mp3": "audio/mpeg", + ".m4a": "audio/mp4", + ".m4b": "audio/mp4", + ".aac": "audio/aac", + ".opus": "audio/opus", + ".ogg": "audio/ogg", + ".flac": "audio/flac", + ".wav": "audio/wav", +} + +// ParseName splits "03 - Great Conservation Principles.mp3" into its track +// number and title. Files without a numeric prefix keep their whole stem as the +// title and report track 0. +func ParseName(filename string) (track int, title string) { + stem := strings.TrimSuffix(filename, filepath.Ext(filename)) + if m := trackPrefix.FindStringSubmatch(stem); m != nil { + n, err := strconv.Atoi(m[1]) + if err == nil { + return n, strings.TrimSpace(m[2]) + } + } + return 0, strings.TrimSpace(stem) +} + +// EnclosureURL joins a base URL to a filename, escaping the filename. +// +// This is the detail that breaks a hand-rolled generator: track names routinely +// contain spaces and ampersands, and pasting them raw produces both an invalid +// URL and invalid XML. Only the final path segment is escaped — escaping the +// whole base would mangle its scheme and host. +func EnclosureURL(baseURL, filename string) (string, error) { + base, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("parsing base URL %q: %w", baseURL, err) + } + if base.Scheme == "" || base.Host == "" { + return "", fmt.Errorf("base URL %q needs a scheme and host", baseURL) + } + base.Path = path.Join(base.Path, filename) + // url.URL.String() escapes the path for us; RawPath stays unset so the + // escaping is derived from Path. + return base.String(), nil +} + +// Build scans Dir and returns the RSS document. +func Build(opts Options, readTags TagReader) (*RSS, error) { + if opts.Title == "" { + return nil, fmt.Errorf("feed title is required") + } + entries, err := os.ReadDir(opts.Dir) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", opts.Dir, err) + } + + epoch := opts.Epoch + if epoch.IsZero() { + epoch = time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC) + } + + type candidate struct { + name string + track int + title string + size int64 + } + var found []candidate + + for _, e := range entries { + if e.IsDir() { + continue + } + mime, isAudio := audioTypes[strings.ToLower(filepath.Ext(e.Name()))] + if !isAudio { + continue + } + _ = mime + info, err := e.Info() + if err != nil { + // A file that vanished mid-scan is not a reason to abandon the + // whole feed, but silently dropping it would be worse than saying so. + return nil, fmt.Errorf("stat %s: %w", e.Name(), err) + } + + track, title := ParseName(e.Name()) + if readTags != nil { + if tagTitle, tagTrack, ok := readTags(filepath.Join(opts.Dir, e.Name())); ok { + if tagTitle != "" { + title = tagTitle + } + if tagTrack > 0 { + track = tagTrack + } + } + } + found = append(found, candidate{e.Name(), track, title, info.Size()}) + } + + // Order by track number, falling back to filename. os.ReadDir is already + // name-sorted, but a feed whose order depends on zero-padding being applied + // consistently is a feed that will eventually play lecture 10 after lecture 1. + sort.SliceStable(found, func(i, j int) bool { + if found[i].track != found[j].track { + // Untracked files (0) sort last rather than first. + if found[i].track == 0 || found[j].track == 0 { + return found[j].track == 0 + } + return found[i].track < found[j].track + } + return found[i].name < found[j].name + }) + + items := make([]Item, 0, len(found)) + for i, c := range found { + encURL, err := EnclosureURL(opts.BaseURL, c.name) + if err != nil { + return nil, err + } + // One hour apart, ascending. Clients sort by date; keeping them ordered + // and distinct means the series plays in sequence whichever way a given + // client sorts. + pub := epoch.Add(time.Duration(i) * time.Hour) + + episode := c.track + if episode == 0 { + episode = i + 1 + } + + items = append(items, Item{ + Title: c.title, + GUID: GUID{IsPermaLink: false, Value: "podkit:" + c.name}, + PubDate: pub.Format(time.RFC1123Z), + Enclosure: Enclosure{ + URL: encURL, + Type: audioTypes[strings.ToLower(filepath.Ext(c.name))], + Length: c.size, + }, + Episode: episode, + }) + } + + description := opts.Description + if description == "" { + description = opts.Title + } + link := opts.Link + if link == "" { + link = opts.BaseURL + } + + return &RSS{ + Version: "2.0", + ITunesNS: "http://www.itunes.com/dtds/podcast-1.0.dtd", + ContentNS: "http://purl.org/rss/1.0/modules/content/", + Channel: Channel{ + Title: opts.Title, + Link: link, + Description: description, + Language: opts.Language, + Author: opts.Author, + ITunesType: "serial", + Items: items, + }, + }, nil +} + +// Marshal renders the feed as a complete XML document. +func Marshal(r *RSS) ([]byte, error) { + out, err := xml.MarshalIndent(r, "", " ") + if err != nil { + return nil, fmt.Errorf("marshalling feed: %w", err) + } + return append([]byte(xml.Header), append(out, '\n')...), nil +} diff --git a/internal/feed/feed_test.go b/internal/feed/feed_test.go new file mode 100644 index 0000000..a529e6f --- /dev/null +++ b/internal/feed/feed_test.go @@ -0,0 +1,343 @@ +package feed + +import ( + "encoding/xml" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func writeFiles(t *testing.T, names ...string) string { + t.Helper() + dir := t.TempDir() + for i, n := range names { + // Distinct sizes so enclosure length is verifiably per-file. + body := strings.Repeat("x", 100+i) + if err := os.WriteFile(filepath.Join(dir, n), []byte(body), 0o644); err != nil { + t.Fatalf("writing %s: %v", n, err) + } + } + return dir +} + +func build(t *testing.T, dir string, opts ...func(*Options)) *RSS { + t.Helper() + o := Options{ + Dir: dir, + BaseURL: "https://example.com/audio/", + Title: "Personal Physics Lectures", + Epoch: time.Date(2020, time.March, 1, 12, 0, 0, 0, time.UTC), + } + for _, f := range opts { + f(&o) + } + r, err := Build(o, nil) + if err != nil { + t.Fatalf("Build: %v", err) + } + return r +} + +func TestParseName(t *testing.T) { + cases := []struct { + in string + track int + title string + }{ + {"01 - The Law of Gravitation.mp3", 1, "The Law of Gravitation"}, + {"07 - Seeking New Laws.mp3", 7, "Seeking New Laws"}, + {"10 - Probability & Uncertainty.mp3", 10, "Probability & Uncertainty"}, + {"3 - Short Prefix.mp3", 3, "Short Prefix"}, + {"04 – En Dash Separator.mp3", 4, "En Dash Separator"}, + {"No Number Here.mp3", 0, "No Number Here"}, + {" Padded Name .mp3", 0, "Padded Name"}, + } + for _, c := range cases { + track, title := ParseName(c.in) + if track != c.track || title != c.title { + t.Errorf("ParseName(%q) = (%d, %q), want (%d, %q)", c.in, track, title, c.track, c.title) + } + } +} + +// Filenames routinely contain spaces and ampersands. Pasting them into the +// enclosure URL unescaped produces both an invalid URL and invalid XML — the +// single most likely way a hand-rolled feed fails to load at all. +func TestEnclosureURL_EscapesFilenames(t *testing.T) { + got, err := EnclosureURL("https://example.com/audio/", "10 - Probability & Uncertainty.mp3") + if err != nil { + t.Fatalf("EnclosureURL: %v", err) + } + if strings.ContainsAny(got, " ") { + t.Errorf("URL still contains a raw space: %q", got) + } + for _, want := range []string{"%20", "&"} { + _ = want + } + if !strings.Contains(got, "10%20-%20Probability") { + t.Errorf("spaces not percent-encoded: %q", got) + } + if !strings.HasPrefix(got, "https://example.com/audio/") { + t.Errorf("base URL mangled: %q", got) + } +} + +func TestEnclosureURL_HandlesMissingTrailingSlash(t *testing.T) { + got, err := EnclosureURL("https://example.com/audio", "01 - One.mp3") + if err != nil { + t.Fatalf("EnclosureURL: %v", err) + } + if !strings.Contains(got, "/audio/01") { + t.Errorf("expected a separator to be inserted, got %q", got) + } +} + +func TestEnclosureURL_RejectsBadBase(t *testing.T) { + for _, bad := range []string{"", "not-a-url", "/just/a/path"} { + if _, err := EnclosureURL(bad, "a.mp3"); err == nil { + t.Errorf("EnclosureURL(%q) should have failed", bad) + } + } +} + +// Zero-padding is a convention, not a guarantee. Ordering has to come from the +// parsed track number, or a series with ten or more parts plays 1, 10, 2. +func TestBuild_OrdersByTrackNumberNotFilename(t *testing.T) { + dir := writeFiles(t, + "1 - One.mp3", + "10 - Ten.mp3", + "2 - Two.mp3", + ) + r := build(t, dir) + + var titles []string + for _, it := range r.Channel.Items { + titles = append(titles, it.Title) + } + want := []string{"One", "Two", "Ten"} + for i := range want { + if titles[i] != want[i] { + t.Fatalf("order = %v, want %v", titles, want) + } + } +} + +// Files with no track number should not displace the numbered sequence. +func TestBuild_UntrackedFilesSortLast(t *testing.T) { + dir := writeFiles(t, "Intro.mp3", "01 - One.mp3", "02 - Two.mp3") + r := build(t, dir) + last := r.Channel.Items[len(r.Channel.Items)-1] + if last.Title != "Intro" { + t.Errorf("last item = %q, want the untracked Intro", last.Title) + } +} + +// Anything that is not audio must be ignored, including the .part files an +// interrupted download leaves behind and any cover art. +func TestBuild_IgnoresNonAudio(t *testing.T) { + dir := writeFiles(t, + "01 - One.mp3", + "cover.jpg", + "02 - Two.mp3.part", + "notes.txt", + ) + r := build(t, dir) + if len(r.Channel.Items) != 1 { + var got []string + for _, i := range r.Channel.Items { + got = append(got, i.Title) + } + t.Errorf("got %d items %v, want only the mp3", len(r.Channel.Items), got) + } +} + +func TestBuild_EnclosureLengthIsTheFileSize(t *testing.T) { + dir := writeFiles(t, "01 - One.mp3") + info, err := os.Stat(filepath.Join(dir, "01 - One.mp3")) + if err != nil { + t.Fatal(err) + } + r := build(t, dir) + if got := r.Channel.Items[0].Enclosure.Length; got != info.Size() { + t.Errorf("Length = %d, want %d", got, info.Size()) + } +} + +// Every item needs a distinct, increasing date or clients are free to order +// the series however they like. +func TestBuild_PubDatesAreDistinctAndAscending(t *testing.T) { + dir := writeFiles(t, "01 - One.mp3", "02 - Two.mp3", "03 - Three.mp3") + r := build(t, dir) + + var prev time.Time + for i, it := range r.Channel.Items { + got, err := time.Parse(time.RFC1123Z, it.PubDate) + if err != nil { + t.Fatalf("item %d has an unparseable pubDate %q: %v", i, it.PubDate, err) + } + if i > 0 && !got.After(prev) { + t.Errorf("item %d pubDate %v is not after the previous %v", i, got, prev) + } + prev = got + } +} + +func TestBuild_EpisodeNumbersFollowTracks(t *testing.T) { + dir := writeFiles(t, "01 - One.mp3", "02 - Two.mp3") + r := build(t, dir) + for i, want := range []int{1, 2} { + if r.Channel.Items[i].Episode != want { + t.Errorf("item %d episode = %d, want %d", i, r.Channel.Items[i].Episode, want) + } + } +} + +// A lecture course must be presented oldest-first; the default for a podcast +// is newest-first, which would start the listener at the final lecture. +func TestBuild_MarkedAsSerial(t *testing.T) { + r := build(t, writeFiles(t, "01 - One.mp3")) + if r.Channel.ITunesType != "serial" { + t.Errorf("itunes:type = %q, want serial", r.Channel.ITunesType) + } +} + +func TestBuild_RequiresATitle(t *testing.T) { + _, err := Build(Options{Dir: t.TempDir(), BaseURL: "https://e.com/"}, nil) + if err == nil { + t.Error("expected an error when no feed title is given") + } +} + +// A missing directory must be an error, not an empty feed that looks like a +// working setup with nothing in it. +func TestBuild_MissingDirectoryIsAnError(t *testing.T) { + _, err := Build(Options{ + Dir: filepath.Join(t.TempDir(), "nope"), BaseURL: "https://e.com/", Title: "T", + }, nil) + if err == nil { + t.Error("expected an error for a missing directory") + } +} + +func TestBuild_ChannelHasRequiredElements(t *testing.T) { + r := build(t, writeFiles(t, "01 - One.mp3")) + if r.Channel.Title == "" { + t.Error("channel title is empty") + } + if r.Channel.Description == "" { + t.Error("channel description is empty — many clients reject a feed without one") + } + if r.Channel.Link == "" { + t.Error("channel link is empty") + } +} + +// ID3 tags, when present, are better than a filename guess. +func TestBuild_PrefersTagsOverFilenames(t *testing.T) { + dir := writeFiles(t, "01 - filename title.mp3") + r, err := Build(Options{ + Dir: dir, BaseURL: "https://example.com/a/", Title: "T", + }, func(string) (string, int, bool) { + return "Tagged Title", 5, true + }) + if err != nil { + t.Fatalf("Build: %v", err) + } + if r.Channel.Items[0].Title != "Tagged Title" { + t.Errorf("title = %q, want the tag value", r.Channel.Items[0].Title) + } + if r.Channel.Items[0].Episode != 5 { + t.Errorf("episode = %d, want the tag track", r.Channel.Items[0].Episode) + } +} + +// A tag reader that finds nothing must not blank out the filename-derived title. +func TestBuild_FallsBackWhenTagsAreAbsent(t *testing.T) { + dir := writeFiles(t, "01 - Filename Title.mp3") + r, err := Build(Options{Dir: dir, BaseURL: "https://e.com/a/", Title: "T"}, + func(string) (string, int, bool) { return "", 0, false }) + if err != nil { + t.Fatalf("Build: %v", err) + } + if r.Channel.Items[0].Title != "Filename Title" { + t.Errorf("title = %q, want the filename fallback", r.Channel.Items[0].Title) + } +} + +// The whole document must be well-formed and round-trip through a parser — +// this is what a podcast client does first, and the thing most likely to fail +// on unescaped content. +func TestMarshal_ProducesParseableXML(t *testing.T) { + dir := writeFiles(t, "10 - Probability & Uncertainty.mp3", "01 - One <thing>.mp3") + r := build(t, dir) + + out, err := Marshal(r) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if !strings.HasPrefix(string(out), xml.Header) { + t.Error("missing XML declaration") + } + + var parsed struct { + Channel struct { + Title string `xml:"title"` + Items []struct { + Title string `xml:"title"` + Enclosure struct { + URL string `xml:"url,attr"` + Length int64 `xml:"length,attr"` + } `xml:"enclosure"` + } `xml:"item"` + } `xml:"channel"` + } + if err := xml.Unmarshal(out, &parsed); err != nil { + t.Fatalf("generated feed does not parse: %v\n%s", err, out) + } + if len(parsed.Channel.Items) != 2 { + t.Fatalf("parsed %d items, want 2", len(parsed.Channel.Items)) + } + for _, it := range parsed.Channel.Items { + if it.Enclosure.URL == "" { + t.Error("an item has no enclosure URL") + } + if it.Enclosure.Length == 0 { + t.Error("an item has a zero enclosure length") + } + } +} + +// The original hand-rolled struct had a field with no xml tag, which the +// marshaller emitted as a stray <EnclosureURL> element. Guard against any +// element leaking in that a reader would not expect. +func TestMarshal_NoStrayElements(t *testing.T) { + out, err := Marshal(build(t, writeFiles(t, "01 - One.mp3"))) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), "EnclosureURL") { + t.Error("stray EnclosureURL element in output") + } + if strings.Contains(string(out), "XMLName") { + t.Error("stray XMLName element in output") + } +} + +func TestMarshal_GUIDIsNotAPermalink(t *testing.T) { + out, _ := Marshal(build(t, writeFiles(t, "01 - One.mp3"))) + if !strings.Contains(string(out), `isPermaLink="false"`) { + t.Error(`guid should carry isPermaLink="false"; it is not a URL`) + } +} + +func TestBuild_EmptyDirectoryProducesAValidEmptyFeed(t *testing.T) { + r := build(t, t.TempDir()) + if len(r.Channel.Items) != 0 { + t.Errorf("got %d items, want 0", len(r.Channel.Items)) + } + if _, err := Marshal(r); err != nil { + t.Errorf("an empty feed should still marshal: %v", err) + } +} |
