diff options
| author | Claude <noreply@anthropic.com> | 2026-09-21 07:05:24 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-09-21 07:05:24 +0000 |
| commit | e3ecae12d7a309eb5a6c8e62910257096cb99375 (patch) | |
| tree | 4f13a1e67dff0af95e5ff49126c75e56e49d83c6 /internal/fetch/direct_test.go | |
| parent | 293a627358553c9f61802caf9d4bc76af9c2e239 (diff) | |
The machine that hosts a feed and the machine that downloads the audio do
not have to be the same, and usually shouldn't be: a datacentre IP is
treated very differently from a home connection by the larger video sites.
Downloading where the person actually is sidesteps that entirely, and means
the server needs no credentials for or route to the source.
- push/ingest: `podkit push` uploads a local library to a remote
`serve --ingest`, asking for a manifest first so only the difference is
sent. Uploads land via a temp file and rename, so an interrupted transfer
cannot be picked up as a short episode. Ingest is off by default and
refuses to start without a token.
- get: downloads a direct audio URL or every enclosure in an RSS feed,
with no extractor involved. Numbers RSS items in reverse so a mirrored
series plays in order rather than backwards.
Two bugs found by running it rather than by testing it:
- A tokened server produced enclosure URLs with no token. The feed loaded,
listed every episode, and all of them 404'd in a real client — the whole
point of the tool, broken in the default configuration. Feeds now carry
the token; regression test asserts enclosures from a tokened feed
actually resolve against a tokened server.
- Ingest ran filepath.Base before validating, so "../evil.mp3" was stored
as "evil.mp3" — contained, but under a name the client never asked for,
leaving push and the server disagreeing about the library. Names are now
validated raw and rejected.
Also: identify with a real user-agent (libsyn 403s Go's default), write
feed.xml via rename so a polling client never reads a truncated feed, and
match ingested file permissions to downloaded ones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal/fetch/direct_test.go')
| -rw-r--r-- | internal/fetch/direct_test.go | 242 |
1 files changed, 242 insertions, 0 deletions
diff --git a/internal/fetch/direct_test.go b/internal/fetch/direct_test.go new file mode 100644 index 0000000..86b9e31 --- /dev/null +++ b/internal/fetch/direct_test.go @@ -0,0 +1,242 @@ +package fetch + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +const sampleRSS = `<?xml version="1.0"?> +<rss version="2.0"> + <channel> + <title>Lectures on Physics</title> + <item> + <title>Three - The Great Conservation Principles</title> + <enclosure url="%s/three.mp3" type="audio/mpeg" length="10"/> + </item> + <item> + <title>Two - The Relation of Mathematics & Physics</title> + <enclosure url="%s/two.mp3" type="audio/mpeg" length="10"/> + </item> + <item> + <title>One - The Law of Gravitation</title> + <enclosure url="%s/one.mp3" type="audio/mpeg" length="10"/> + </item> + </channel> +</rss>` + +func rssServer(t *testing.T) *httptest.Server { + t.Helper() + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, ".mp3") { + fmt.Fprintf(w, "audio for %s", r.URL.Path) + return + } + w.Header().Set("Content-Type", "application/rss+xml") + fmt.Fprintf(w, sampleRSS, srv.URL, srv.URL, srv.URL) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestDiscoverDirectAudioURL(t *testing.T) { + sources, _, err := Discover(context.Background(), "https://example.org/talks/01%20-%20Intro.mp3", nil) + if err != nil { + t.Fatal(err) + } + if len(sources) != 1 { + t.Fatalf("got %d sources", len(sources)) + } + if sources[0].Name != "01 - Intro.mp3" { + t.Errorf("name %q — the URL escape should be decoded", sources[0].Name) + } +} + +func TestDiscoverRSSEnclosures(t *testing.T) { + srv := rssServer(t) + sources, title, err := Discover(context.Background(), srv.URL+"/feed.xml", nil) + if err != nil { + t.Fatal(err) + } + if title != "Lectures on Physics" { + t.Errorf("title %q", title) + } + if len(sources) != 3 { + t.Fatalf("got %d sources", len(sources)) + } +} + +// RSS is newest-first by convention, so the last item is episode 1. Numbering +// has to run in reverse or a mirrored course plays backwards. +func TestDiscoverNumbersRSSInListeningOrder(t *testing.T) { + srv := rssServer(t) + sources, _, err := Discover(context.Background(), srv.URL+"/feed.xml", nil) + if err != nil { + t.Fatal(err) + } + + byTrack := map[int]string{} + for _, s := range sources { + byTrack[s.Track] = s.Name + } + if !strings.Contains(byTrack[1], "One - The Law of Gravitation") { + t.Errorf("track 1 is %q, want the feed's last item", byTrack[1]) + } + if !strings.Contains(byTrack[3], "Three - The Great Conservation") { + t.Errorf("track 3 is %q, want the feed's first item", byTrack[3]) + } + if !strings.HasPrefix(byTrack[1], "01 - ") { + t.Errorf("track 1 name %q is not zero-padded", byTrack[1]) + } +} + +func TestDiscoverRejectsNonFeed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "<html><body>not a feed</body></html>") + })) + defer srv.Close() + + if _, _, err := Discover(context.Background(), srv.URL, nil); err == nil { + t.Error("expected an error for a page that is not a feed") + } +} + +func TestDiscoverRejectsBadURL(t *testing.T) { + for _, u := range []string{"", "not-a-url", "/just/a/path"} { + if _, _, err := Discover(context.Background(), u, nil); err == nil { + t.Errorf("%q was accepted", u) + } + } +} + +func TestDownloadWritesFiles(t *testing.T) { + srv := rssServer(t) + dir := t.TempDir() + + sources, _, err := Discover(context.Background(), srv.URL+"/feed.xml", nil) + if err != nil { + t.Fatal(err) + } + if err := Download(context.Background(), sources, dir, nil, nil); err != nil { + t.Fatal(err) + } + + entries, _ := os.ReadDir(dir) + if len(entries) != 3 { + t.Fatalf("got %d files", len(entries)) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".part") { + t.Errorf("partial file %q left behind", e.Name()) + } + } +} + +// Re-running must not re-download; this is what makes a long series resumable. +func TestDownloadSkipsExisting(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + fmt.Fprint(w, "audio") + })) + defer srv.Close() + + dir := t.TempDir() + sources := []Source{{URL: srv.URL + "/a.mp3", Name: "01 - A.mp3", Track: 1}} + + for i := 0; i < 3; i++ { + if err := Download(context.Background(), sources, dir, nil, nil); err != nil { + t.Fatal(err) + } + } + if hits != 1 { + t.Errorf("downloaded %d times, want 1", hits) + } +} + +// A failed download must not leave something the feed generator would treat as +// a complete episode. +func TestDownloadLeavesNothingOnFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "gone", http.StatusNotFound) + })) + defer srv.Close() + + dir := t.TempDir() + err := Download(context.Background(), []Source{{URL: srv.URL + "/a.mp3", Name: "01 - A.mp3"}}, dir, nil, nil) + if err == nil { + t.Fatal("expected an error") + } + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Errorf("left %d file(s) behind after a failed download", len(entries)) + } +} + +func TestSanitiseRemovesPathCharacters(t *testing.T) { + cases := []struct{ in, want string }{ + {"A/B: The Sequel", "AB The Sequel"}, + {"What? Why!", "What Why!"}, + {" spaced out ", "spaced out"}, + // Separators are dropped and the leading dots trimmed, so a name that + // tried to traverse comes out as an ordinary filename. + {"../../etc/passwd", "etcpasswd"}, + {"", "episode"}, + {"...", "episode"}, + } + for _, c := range cases { + if got := sanitise(c.in); got != c.want { + t.Errorf("sanitise(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// A sanitised name must never be able to escape the download directory. +func TestSanitisedNamesStayInDirectory(t *testing.T) { + dir := t.TempDir() + for _, in := range []string{"../escape", "../../escape", "/absolute", "a/b/c"} { + joined := filepath.Join(dir, sanitise(in)+".mp3") + if !strings.HasPrefix(joined, dir+string(filepath.Separator)) { + t.Errorf("sanitise(%q) produced a path outside the directory: %s", in, joined) + } + } +} + +func TestExtOfPrefersURLThenMIME(t *testing.T) { + sources, _, err := Discover(context.Background(), "https://example.org/a/file.opus", nil) + if err != nil { + t.Fatal(err) + } + if filepath.Ext(sources[0].Name) != ".opus" { + t.Errorf("name %q", sources[0].Name) + } +} + +// An enclosure URL with no useful extension has to fall back to the declared +// MIME type, or the file lands as .mp3 and no player reads its tags. +func TestExtOfFallsBackToMIMEType(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/stream") { + fmt.Fprint(w, "audio") + return + } + fmt.Fprintf(w, `<?xml version="1.0"?><rss version="2.0"><channel><title>T</title> + <item><title>Only</title><enclosure url="%s/stream?id=7" type="audio/mp4"/></item> + </channel></rss>`, srv.URL) + })) + defer srv.Close() + + sources, _, err := Discover(context.Background(), srv.URL+"/feed.xml", nil) + if err != nil { + t.Fatal(err) + } + if filepath.Ext(sources[0].Name) != ".m4a" { + t.Errorf("name %q, want a .m4a extension from the audio/mp4 type", sources[0].Name) + } +} |
