summaryrefslogtreecommitdiff
path: root/internal/fetch/fetch_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-21 06:50:42 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-21 06:50:42 +0000
commit293a627358553c9f61802caf9d4bc76af9c2e239 (patch)
treee4769ac949659953bd5e09cebea9d0697e82548f /internal/fetch/fetch_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/fetch/fetch_test.go')
-rw-r--r--internal/fetch/fetch_test.go144
1 files changed, 144 insertions, 0 deletions
diff --git a/internal/fetch/fetch_test.go b/internal/fetch/fetch_test.go
new file mode 100644
index 0000000..e55b508
--- /dev/null
+++ b/internal/fetch/fetch_test.go
@@ -0,0 +1,144 @@
+package fetch
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+)
+
+func TestPrettifyName_RestoresSpaces(t *testing.T) {
+ got := prettifyName("01 - The_Law_of_Gravitation.mp3")
+ if got != "01 - The Law of Gravitation.mp3" {
+ t.Errorf("got %q", got)
+ }
+}
+
+// yt-dlp's --restrict-filenames collapses odd characters into underscores, and
+// replacing them can leave runs of spaces behind.
+func TestPrettifyName_CollapsesRepeatedSpaces(t *testing.T) {
+ got := prettifyName("02 - A___Gap.mp3")
+ if got != "02 - A Gap.mp3" {
+ t.Errorf("got %q", got)
+ }
+}
+
+// Sources whose titles already begin with a track number produce
+// "01 - 01 - April.mp3" once the output template prepends its own index. Seen
+// for real against a LibriVox collection on the first live run.
+func TestPrettifyName_CollapsesDuplicatedTrackPrefix(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"01 - 01 - April.mp3", "01 - April.mp3"},
+ {"02 - 02 - Ave Atque Vale.mp3", "02 - Ave Atque Vale.mp3"},
+ {"03 - 03_-_The_Average_Man.mp3", "03 - The Average Man.mp3"},
+ {"10 - 10 - Tenth.mp3", "10 - Tenth.mp3"},
+ }
+ for _, c := range cases {
+ if got := prettifyName(c.in); got != c.want {
+ t.Errorf("prettifyName(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+// Only collapse when the two numbers agree. A title that genuinely starts with
+// a different number is not a duplicated prefix and must be left alone.
+func TestPrettifyName_KeepsDistinctLeadingNumbers(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"01 - 1964 - The Messenger Lectures.mp3", "01 - 1964 - The Messenger Lectures.mp3"},
+ {"05 - 12 Angry Men.mp3", "05 - 12 Angry Men.mp3"},
+ }
+ for _, c := range cases {
+ if got := prettifyName(c.in); got != c.want {
+ t.Errorf("prettifyName(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestPrettifyName_LeavesCleanNamesAlone(t *testing.T) {
+ for _, n := range []string{"01 - April.mp3", "Introduction.mp3"} {
+ if got := prettifyName(n); got != n {
+ t.Errorf("prettifyName(%q) = %q, want it unchanged", n, got)
+ }
+ }
+}
+
+func TestRename_RenamesOnDisk(t *testing.T) {
+ dir := t.TempDir()
+ for _, n := range []string{"01 - 01 - April.mp3", "02 - The_Average_Man.mp3", ".podkit-archive"} {
+ if err := os.WriteFile(filepath.Join(dir, n), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if _, err := Rename(dir); err != nil {
+ t.Fatalf("Rename: %v", err)
+ }
+
+ entries, _ := os.ReadDir(dir)
+ var names []string
+ for _, e := range entries {
+ names = append(names, e.Name())
+ }
+ sort.Strings(names)
+
+ want := []string{".podkit-archive", "01 - April.mp3", "02 - The Average Man.mp3"}
+ if len(names) != len(want) {
+ t.Fatalf("got %v, want %v", names, want)
+ }
+ for i := range want {
+ if names[i] != want[i] {
+ t.Errorf("got %v, want %v", names, want)
+ break
+ }
+ }
+}
+
+// Renaming must never destroy an existing file to make a name prettier.
+func TestRename_DoesNotClobber(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "01 - April.mp3"), []byte("original"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "01 - 01 - April.mp3"), []byte("candidate"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := Rename(dir); err != nil {
+ t.Fatalf("Rename: %v", err)
+ }
+
+ body, err := os.ReadFile(filepath.Join(dir, "01 - April.mp3"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(body) != "original" {
+ t.Error("an existing file was overwritten by the rename")
+ }
+}
+
+func TestRename_MissingDirectoryIsAnError(t *testing.T) {
+ if _, err := Rename(filepath.Join(t.TempDir(), "nope")); err == nil {
+ t.Error("expected an error for a missing directory")
+ }
+}
+
+// Check must explain what to install, not just report a lookup failure.
+func TestErrNotInstalled_SaysHowToFixIt(t *testing.T) {
+ e := &ErrNotInstalled{}
+ for _, want := range []string{"yt-dlp", "pip install", "ffmpeg"} {
+ if !contains(e.Error(), want) {
+ t.Errorf("error message should mention %q; got %q", want, e.Error())
+ }
+ }
+}
+
+func contains(h, n string) bool {
+ return len(h) >= len(n) && (func() bool {
+ for i := 0; i+len(n) <= len(h); i++ {
+ if h[i:i+len(n)] == n {
+ return true
+ }
+ }
+ return false
+ })()
+}