// 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 // Token, if set, is appended to every enclosure URL. // // This is not optional bookkeeping: a podcast client fetches an enclosure as // a plain GET with nothing carried over from the feed request. If the server // requires a token and the enclosure URLs lack one, the feed loads, shows // every episode, and no episode will ever download. Token string } // 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", } // IsAudio reports whether a filename is one podkit treats as an episode. // // Exported because the server and the uploader need exactly the same answer as // the feed generator: a file the server accepts but the generator ignores would // upload successfully and then never appear in the feed. func IsAudio(filename string) bool { _, ok := audioTypes[strings.ToLower(filepath.Ext(filename))] return ok } // MIMEType returns the enclosure content type for a filename, or "" if it is // not audio podkit recognises. func MIMEType(filename string) string { return audioTypes[strings.ToLower(filepath.Ext(filename))] } // 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. // A token, when supplied, is carried on the URL, because that is the only thing // a podcast client will send back. func EnclosureURL(baseURL, filename, token 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) if token != "" { q := base.Query() q.Set("k", token) base.RawQuery = q.Encode() } // 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 } if !IsAudio(e.Name()) { continue } 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, opts.Token) 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: MIMEType(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 }