summaryrefslogtreecommitdiff
path: root/internal/feed
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/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.go271
-rw-r--r--internal/feed/feed_test.go343
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)
+ }
+}