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/feed_test.go | |
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/feed_test.go')
| -rw-r--r-- | internal/feed/feed_test.go | 343 |
1 files changed, 343 insertions, 0 deletions
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) + } +} |
