diff options
Diffstat (limited to 'internal/feed/feed.go')
| -rw-r--r-- | internal/feed/feed.go | 271 |
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 +} |
