// Package feed parses a podcast RSS feed down to the few fields the server needs. package feed import ( "encoding/xml" "fmt" "sort" "strconv" "strings" "time" ) // Episode is one audio episode as its feed describes it. type Episode struct { GUID string Title string // EnclosureURL is where the audio is, and the fallback identity when a GUID moves. EnclosureURL string EnclosureType string PublishedUnix int64 // DeclaredDurationMs is the feed's claim, which is routinely wrong -- a show using dynamic // ad insertion declares the ad-free length. Useful for ordering, not for arithmetic. DeclaredDurationMs int64 // TranscriptURL is a Podcasting 2.0 transcript in a format that carries timestamps, or // empty. The publisher's own words, which is exact where speech recognition is a guess -- // but it describes the publisher's master, so whether it describes the file the server // mirrored is a separate question. See transcript.Fits. TranscriptURL string TranscriptType string } // Channel is a show and its audio episodes, newest first. type Channel struct { Title string Episodes []Episode } type rss struct { Channel struct { Title string `xml:"title"` Items []struct { Title string `xml:"title"` GUID string `xml:"guid"` PubDate string `xml:"pubDate"` Duration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"` Enclosure struct { URL string `xml:"url,attr"` Type string `xml:"type,attr"` } `xml:"enclosure"` // Both spellings of the Podcasting 2.0 namespace. The specification was published // under http:// and the feeds in the wild now declare https://; Go matches a // namespace by exact string, so taking only one silently drops the other's // transcripts rather than failing. Transcripts []transcriptTag `xml:"https://podcastindex.org/namespace/1.0 transcript"` OldNamespace []transcriptTag `xml:"http://podcastindex.org/namespace/1.0 transcript"` } `xml:"item"` } `xml:"channel"` } // Parse reads a feed and returns its audio episodes, newest first. // // Audio only: video was cut from the app's scope, and a YouTube or Facebook episode has no // enclosure the server could fetch regardless -- its audio only exists once yt-dlp has resolved // it, which stays the phone's job. func Parse(raw []byte) (*Channel, error) { var doc rss if err := xml.Unmarshal(raw, &doc); err != nil { return nil, fmt.Errorf("parse feed: %w", err) } if doc.Channel.Title == "" && len(doc.Channel.Items) == 0 { return nil, fmt.Errorf("parse feed: no channel found") } out := &Channel{Title: strings.TrimSpace(doc.Channel.Title)} for _, item := range doc.Channel.Items { if !isAudio(item.Enclosure.Type, item.Enclosure.URL) { continue } transcriptURL, transcriptType := bestTranscript(append(item.Transcripts, item.OldNamespace...)) out.Episodes = append(out.Episodes, Episode{ GUID: strings.TrimSpace(item.GUID), Title: strings.TrimSpace(item.Title), EnclosureURL: strings.TrimSpace(item.Enclosure.URL), EnclosureType: strings.TrimSpace(item.Enclosure.Type), PublishedUnix: parseDate(item.PubDate), DeclaredDurationMs: parseDuration(item.Duration), TranscriptURL: transcriptURL, TranscriptType: transcriptType, }) } // Newest first. Feeds are not reliably ordered, and the retention rule depends on this. sort.SliceStable(out.Episodes, func(i, j int) bool { return out.Episodes[i].PublishedUnix > out.Episodes[j].PublishedUnix }) return out, nil } // transcriptRank orders the transcript formats a publisher may offer. An item routinely carries // several at once, and the MIME types in the wild disagree about the same format: both // application/srt and application/x-subrip are SRT. // // WebVTT first only because it is the format with a specification that says what a cue is; SRT // is equally usable and ranks next. text/plain, text/html and application/json are absent // deliberately -- they carry no timestamps, so a cue cannot be seeked to or placed, and a // transcript that can do neither is worse than none at all because storing it stops the episode // being looked at again. var transcriptRank = map[string]int{ "text/vtt": 1, "text/webvtt": 1, "application/srt": 2, "application/x-subrip": 2, "text/srt": 2, } // transcriptTag is one element. type transcriptTag struct { URL string `xml:"url,attr"` Type string `xml:"type,attr"` } func bestTranscript(offered []transcriptTag) (string, string) { bestRank := 0 var bestURL, bestType string for _, t := range offered { url := strings.TrimSpace(t.URL) mediaType := strings.ToLower(strings.TrimSpace(t.Type)) if i := strings.IndexByte(mediaType, ';'); i >= 0 { mediaType = strings.TrimSpace(mediaType[:i]) } rank, ok := transcriptRank[mediaType] if !ok || url == "" { continue } if bestRank == 0 || rank < bestRank { bestRank, bestURL, bestType = rank, url, mediaType } } return bestURL, bestType } func isAudio(mediaType, url string) bool { if url == "" { return false } if strings.HasPrefix(strings.ToLower(mediaType), "audio/") { return true } // Some feeds omit or mistype the enclosure type; fall back to the extension rather than // dropping a real episode over a publisher's metadata slip. if mediaType == "" { lower := strings.ToLower(url) for _, ext := range []string{".mp3", ".m4a", ".aac", ".ogg", ".opus", ".flac", ".wav"} { if strings.Contains(lower, ext) { return true } } } return false } // dateLayouts covers what podcast feeds actually emit, which is RFC 1123 with and without a // named zone plus a few near-misses. var dateLayouts = []string{ time.RFC1123Z, time.RFC1123, time.RFC822Z, time.RFC822, time.RFC3339, "Mon, 2 Jan 2006 15:04:05 -0700", "Mon, 2 Jan 2006 15:04:05 MST", "Mon, 02 Jan 2006 15:04 -0700", "2 Jan 2006 15:04:05 -0700", } func parseDate(raw string) int64 { raw = strings.TrimSpace(raw) if raw == "" { return 0 } for _, layout := range dateLayouts { if t, err := time.Parse(layout, raw); err == nil { return t.Unix() } } return 0 } // parseDuration reads itunes:duration, which appears as bare seconds, mm:ss, or hh:mm:ss. func parseDuration(raw string) int64 { raw = strings.TrimSpace(raw) if raw == "" { return 0 } parts := strings.Split(raw, ":") var total int64 for _, p := range parts { n, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64) if err != nil { return 0 } total = total*60 + n } return total * 1000 }