// 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 ( "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. Returns an error when the // XML is unparseable or the document is not an RSS channel. func ParsePodcastFeed(data []byte) (*PodcastFeedXML, error) { var feed PodcastFeedXML if err := xml.Unmarshal(data, &feed); 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 }