summaryrefslogtreecommitdiff
path: root/internal/feed/feed.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/feed/feed.go')
-rw-r--r--internal/feed/feed.go271
1 files changed, 271 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
+}