// RSS 2.0 + iTunes + Podcasting-2.0 feed parsing for the podcast // fetcher (plan podcast-integration.md §5/§7). Stdlib encoding/xml only // — no new deps. // // Namespace strategy: iTunes fields MUST be namespace-qualified in the // struct tags (the itunes URI) because their local names collide with // core RSS ("title", "subtitle", "author", "summary", "explicit", // "image", "duration"). Podcasting-2.0 fields (podcast:guid, // podcast:transcript, ...) have collision-free local names, so they // match on local name — encoding/xml matches Name.Local when the tag // carries no namespace, which also tolerates both declared namespace // URIs the ecosystem uses. // // Out of scope at parse time (plan §5): podcast:value, liveItem, // soundbite, alternateEnclosure, socialInteract, remoteItem/podroll, // locked/block, txt, chat. lastBuildDate/ttl are poll hints only. package services import ( "bytes" "encoding/xml" "fmt" "strconv" "strings" "time" ) // itunesNS is the canonical iTunes podcast namespace URI. const itunesNS = "http://www.itunes.com/dtds/podcast-1.0.dtd" // PodcastFeedXML is one parsed RSS document. type PodcastFeedXML struct { XMLName xml.Name `xml:"rss"` Channel PodcastChannelXML `xml:"channel"` } type PodcastChannelXML struct { Title string `xml:"title"` Link string `xml:"link"` Language string `xml:"language"` Description string `xml:"description"` // iTunes channel namespace. ITunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"` ITunesOwner struct { Name string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd name"` Email string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd email"` } `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd owner"` ITunesImage struct { Href string `xml:"href,attr"` } `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"` ITunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"` ITunesType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd type"` ITunesCategory struct { Text string `xml:"text,attr"` Subcategory struct { Text string `xml:"text,attr"` } `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"` } `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"` ITunesNewFeedURL string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd new-feed-url"` ITunesComplete string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd complete"` // Podcasting 2.0 (matched on local name; see file comment). PodcastGUID string `xml:"guid"` PodcastMedium string `xml:"medium"` LastBuildDate string `xml:"lastBuildDate"` Items []PodcastItemXML `xml:"item"` } type PodcastItemXML struct { GUID string `xml:"guid"` Title string `xml:"title"` ITunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"` Subtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd subtitle"` // Description resolution order (plan §5): description > // itunes:summary > content:encoded. Description string `xml:"description"` ITunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"` ContentEncoded string `xml:"encoded"` // Item author: itunes:author preferred, then RSS author, then dc:creator. ITunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"` Author string `xml:"author"` Creator string `xml:"http://purl.org/dc/elements/1.1/ creator"` ITunesImage struct { Href string `xml:"href,attr"` } `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"` ITunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"` ITunesDuration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"` ITunesEpisode string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episode"` ITunesSeason string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd season"` PubDate string `xml:"pubDate"` Enclosure struct { URL string `xml:"url,attr"` Length int64 `xml:"length,attr"` Type string `xml:"type,attr"` } `xml:"enclosure"` // Podcasting 2.0: store-now, feature-later URLs. Transcript struct { URL string `xml:"url,attr"` } `xml:"transcript"` Chapters struct { URL string `xml:"url,attr"` } `xml:"chapters"` Persons []struct { Href string `xml:"href,attr"` Role string `xml:"role,attr"` } `xml:"person"` } // ParsePodcastFeed decodes a feed document via a namespace-aware token // decoder (see decodePodcastFeed). Returns an error when the XML is // unparseable or the document is not an RSS channel. func ParsePodcastFeed(data []byte) (*PodcastFeedXML, error) { feed, err := decodePodcastFeed(data) if err != nil { return nil, fmt.Errorf("feed parse: %w", err) } if feed.Channel.Title == "" && len(feed.Channel.Items) == 0 { return nil, fmt.Errorf("feed parse: not an RSS channel (no title, no items)") } return feed, nil } // EpisodeTitle prefers itunes:title over the raw RSS title (plan §5). func (it *PodcastItemXML) EpisodeTitle() string { if t := strings.TrimSpace(it.ITunesTitle); t != "" { return t } return strings.TrimSpace(it.Title) } // EpisodeDescription resolves the description fallback chain. func (it *PodcastItemXML) EpisodeDescription() string { for _, c := range []string{it.Description, it.ITunesSummary, it.ContentEncoded} { if t := strings.TrimSpace(c); t != "" { return t } } return "" } // EpisodeAuthor resolves the author fallback chain. func (it *PodcastItemXML) EpisodeAuthor() string { for _, c := range []string{it.ITunesAuthor, it.Author, it.Creator} { if t := strings.TrimSpace(c); t != "" { return t } } return "" } // EpisodeExplicit maps the itunes:explicit spelling variants to bool. func (it *PodcastItemXML) EpisodeExplicit() bool { return parseExplicit(it.ITunesExplicit) } func parseExplicit(v string) bool { switch strings.ToLower(strings.TrimSpace(v)) { case "true", "yes", "explicit", "1": return true } return false } // HostsAndGuests splits podcast:person entries by role (plan §5). func (it *PodcastItemXML) HostsAndGuests() (hosts []string, guests []string) { for _, p := range it.Persons { name := p.Href if name == "" { continue } switch strings.ToLower(p.Role) { case "guest": guests = append(guests, name) default: // host and unlabeled roles surface as hosts hosts = append(hosts, name) } } return hosts, guests } // parseITunesDuration accepts the three spellings the spec allows — // plain seconds, HH:MM:SS, MM:SS — plus a best-effort prose fallback // ("58 minutes"). Returns 0 for anything unparseable (the column stays // NULL; ffprobe reconciles after download anyway). func parseITunesDuration(v string) int { v = strings.TrimSpace(v) if v == "" { return 0 } if secs, err := strconv.Atoi(v); err == nil { return secs } parts := strings.Split(v, ":") if len(parts) <= 3 { total := 0 ok := true for _, p := range parts { n, err := strconv.Atoi(strings.TrimSpace(p)) if err != nil { ok = false break } total = total*60 + n } if ok { return total } } // Prose best-effort: " minutes/hours/seconds". lower := strings.ToLower(v) fields := strings.Fields(lower) for i, f := range fields { if n, err := strconv.Atoi(f); err == nil && i+1 < len(fields) { switch { case strings.HasPrefix(fields[i+1], "hour"): return n * 3600 case strings.HasPrefix(fields[i+1], "minute"): return n * 60 case strings.HasPrefix(fields[i+1], "second"): return n } } } return 0 } // parseFeedTime parses the RFC-822/1123 variants feeds actually emit. func parseFeedTime(v string) (t time.Time, ok bool) { v = strings.TrimSpace(v) if v == "" { return time.Time{}, false } layouts := []string{ time.RFC1123Z, time.RFC1123, time.RFC822Z, time.RFC822, "Mon, 2 Jan 2006 15:04:05 -0700", "Mon, 2 Jan 2006 15:04:05 MST", "2 Jan 2006 15:04:05 -0700", "2 Jan 2006 15:04:05 MST", time.ANSIC, time.UnixDate, } for _, layout := range layouts { if t, err := time.Parse(layout, v); err == nil { return t, true } } return time.Time{}, false } // ============================================================================ // Namespace-aware decoding. // // WHY NOT encoding/xml: its field matching for UNQUALIFIED tags ignores // namespaces — a field tagged `xml:"title"` also matches // `` (first field in declaration order wins, document // order clobbers), so the two collide unpredictably. RSS feeds stack // core RSS, iTunes and Podcasting-2.0 namespaces over the SAME local // names (title, author, description, guid), so a token-loop decoder // dispatching on (namespace, local) is the only deterministic shape. // It also tolerates BOTH podcast-namespace URIs the ecosystem uses and // arbitrary prefix spellings, because matching is on resolved // namespace, never on the prefix string. // ============================================================================ // decodePodcastFeed runs the token loop and fills the document struct. func decodePodcastFeed(data []byte) (*PodcastFeedXML, error) { dec := xml.NewDecoder(bytes.NewReader(data)) dec.Strict = false // feeds in the wild carry stray entities; be liberal feed := &PodcastFeedXML{} // Find the start element (tolerate comments/PIs/doctype). for { tok, err := dec.Token() if err != nil { return nil, err } if se, ok := tok.(xml.StartElement); ok { if se.Name.Local == "rss" { break } // Some feeds wrap in nothing else; only rss is accepted. return nil, fmt.Errorf("unexpected root element %q", se.Name.Local) } } for { tok, err := dec.Token() if err != nil { return nil, err } switch t := tok.(type) { case xml.StartElement: if t.Name.Local == "channel" { if err := decodeChannel(dec, &feed.Channel); err != nil { return nil, err } continue } if err := skipElement(dec); err != nil { return nil, err } case xml.EndElement: // — done. return feed, nil } } } // decodeChannel walks the channel's children. func decodeChannel(dec *xml.Decoder, ch *PodcastChannelXML) error { for { tok, err := dec.Token() if err != nil { return err } switch t := tok.(type) { case xml.StartElement: ns, local := t.Name.Space, t.Name.Local switch { case ns == "" && local == "title": ch.Title, err = elementText(dec) case ns == "" && local == "description": ch.Description, err = elementText(dec) case ns == "" && local == "language": ch.Language, err = elementText(dec) case ns == "" && local == "link": ch.Link, err = elementText(dec) case ns == "" && local == "lastBuildDate": ch.LastBuildDate, err = elementText(dec) case ns == itunesNS && local == "author": ch.ITunesAuthor, err = elementText(dec) case ns == itunesNS && local == "owner": err = decodeITunesOwner(dec, ch) case ns == itunesNS && local == "image": ch.ITunesImage.Href = attrValue(t.Attr, "href") err = skipElement(dec) case ns == itunesNS && local == "explicit": ch.ITunesExplicit, err = elementText(dec) case ns == itunesNS && local == "type": ch.ITunesType, err = elementText(dec) case ns == itunesNS && local == "new-feed-url": ch.ITunesNewFeedURL, err = elementText(dec) case ns == itunesNS && local == "complete": ch.ITunesComplete, err = elementText(dec) case ns == itunesNS && local == "category": ch.ITunesCategory.Text = attrValue(t.Attr, "text") err = decodeITunesCategory(dec, ch) case ns != "" && local == "guid": // podcast:guid (either declared URI); core RSS has no // channel-level guid, so any namespaced guid is it. ch.PodcastGUID, err = elementText(dec) case ns != "" && local == "medium": ch.PodcastMedium, err = elementText(dec) case ns == "" && local == "item": var item PodcastItemXML if err := decodeItem(dec, &item); err != nil { return err } ch.Items = append(ch.Items, item) continue default: err = skipElement(dec) } if err != nil { return err } case xml.EndElement: return nil // } } } func decodeITunesOwner(dec *xml.Decoder, ch *PodcastChannelXML) error { for { tok, err := dec.Token() if err != nil { return err } switch t := tok.(type) { case xml.StartElement: if t.Name.Space == itunesNS && t.Name.Local == "name" { if ch.ITunesOwner.Name, err = elementText(dec); err != nil { return err } continue } if t.Name.Space == itunesNS && t.Name.Local == "email" { if ch.ITunesOwner.Email, err = elementText(dec); err != nil { return err } continue } if err := skipElement(dec); err != nil { return err } case xml.EndElement: return nil } } } func decodeITunesCategory(dec *xml.Decoder, ch *PodcastChannelXML) error { for { tok, err := dec.Token() if err != nil { return err } switch t := tok.(type) { case xml.StartElement: if t.Name.Space == itunesNS && t.Name.Local == "category" { ch.ITunesCategory.Subcategory.Text = attrValue(t.Attr, "text") } if err := skipElement(dec); err != nil { return err } case xml.EndElement: return nil } } } // decodeItem walks one . func decodeItem(dec *xml.Decoder, it *PodcastItemXML) error { for { tok, err := dec.Token() if err != nil { return err } switch t := tok.(type) { case xml.StartElement: ns, local := t.Name.Space, t.Name.Local switch { case ns == "" && local == "guid": it.GUID, err = elementText(dec) case ns == "" && local == "title": it.Title, err = elementText(dec) case ns == "" && local == "description": it.Description, err = elementText(dec) case ns == "" && local == "pubDate": it.PubDate, err = elementText(dec) case ns == "" && local == "author": it.Author, err = elementText(dec) case ns == "" && local == "enclosure": it.Enclosure.URL = attrValue(t.Attr, "url") it.Enclosure.Type = attrValue(t.Attr, "type") if raw := attrValue(t.Attr, "length"); raw != "" { it.Enclosure.Length, _ = strconv.ParseInt(raw, 10, 64) } err = skipElement(dec) case ns == itunesNS && local == "title": it.ITunesTitle, err = elementText(dec) case ns == itunesNS && local == "subtitle": it.Subtitle, err = elementText(dec) case ns == itunesNS && local == "summary": it.ITunesSummary, err = elementText(dec) case ns == itunesNS && local == "author": it.ITunesAuthor, err = elementText(dec) case ns == itunesNS && local == "explicit": it.ITunesExplicit, err = elementText(dec) case ns == itunesNS && local == "duration": it.ITunesDuration, err = elementText(dec) case ns == itunesNS && local == "episode": it.ITunesEpisode, err = elementText(dec) case ns == itunesNS && local == "season": it.ITunesSeason, err = elementText(dec) case ns == itunesNS && local == "image": it.ITunesImage.Href = attrValue(t.Attr, "href") err = skipElement(dec) case ns == "http://purl.org/dc/elements/1.1/" && local == "creator": it.Creator, err = elementText(dec) case ns == "http://purl.org/rss/1.0/modules/content/" && local == "encoded": it.ContentEncoded, err = elementText(dec) case ns != "" && local == "transcript": it.Transcript.URL = attrValue(t.Attr, "url") err = skipElement(dec) case ns != "" && local == "chapters": it.Chapters.URL = attrValue(t.Attr, "url") err = skipElement(dec) case ns != "" && local == "person": href, role := attrValue(t.Attr, "href"), attrValue(t.Attr, "role") it.Persons = append(it.Persons, struct { Href string `xml:"href,attr"` Role string `xml:"role,attr"` }{Href: href, Role: role}) err = skipElement(dec) default: err = skipElement(dec) } if err != nil { return err } case xml.EndElement: return nil // } } } // elementText accumulates chardata until this element closes (leaf-ish // elements: CDATA sections come through as xml.CharData and are // included; child elements are skipped). func elementText(dec *xml.Decoder) (string, error) { var sb strings.Builder depth := 1 for depth > 0 { tok, err := dec.Token() if err != nil { return "", err } switch t := tok.(type) { case xml.StartElement: depth++ case xml.EndElement: depth-- case xml.CharData: if depth == 1 { sb.Write(t) } } } return strings.TrimSpace(sb.String()), nil } // skipElement consumes an element we don't model (depth-safe). func skipElement(dec *xml.Decoder) error { depth := 1 for depth > 0 { tok, err := dec.Token() if err != nil { return err } switch tok.(type) { case xml.StartElement: depth++ case xml.EndElement: depth-- } } return nil } // attrValue returns the named attribute (local-name match; the // attributes we read carry no namespace). func attrValue(attrs []xml.Attr, local string) string { for _, a := range attrs { if a.Name.Local == local { return strings.TrimSpace(a.Value) } } return "" }