Files
bookhoard/internal/services/podcast_rss.go
T
John O'Keefe 49b0b6eeb9 test(podcasts): integration suite + RLS auditor + contract widening (task 1.10)
cmd/server/tests/podcast_test.go — six suites, the shared-dev-DB
doctrine (test*-named libraries, cascading cleanup, no binary
fixtures):

- TestPodcastRLSSurfaces: THE AUDITOR (invariant 13) — non-subscriber
  404 on feed detail + SSR page (existence leak), episodes absent from
  generic media reads, admin escape, subscriber visibility, then the
  hidden-library flip hiding an already-subscribed feed (the feed
  policy's library-visibility join). Seeding runs as the FIRST ADMIN —
  the seed identity has neither subscription nor users row, so podcast
  INSERT..RETURNING (SELECT policy under FORCE) 42501s for it;
  production pods run as exactly that admin (invariant 12). All direct
  row probes read through the admin scope for the same reason.
- TestPodcastAddFeedFlow: member add + subscribe + metadata-only
  upsert; duplicate-URL dedupe (no second row); allow_member_feeds
  gate (member 403 / admin 201 / subscribe-still-allowed).
- TestPodcastShrunkenFeedRetention: feed purges an episode from the
  XML, both rows persist (upsert-without-delete pin, D14).
- TestPodcastEditSegregation: generic edit 400 + write-to-file refusal
  for episodes; control book edit unaffected.
- TestPodcastDownloadWorker (ffmpeg-generated tone at runtime): file
  lands at <download-root>/<feed-slug>/NNNN-…, audio_metadata probed;
  re-download of a downloaded episode never re-fetches (idempotency
  pin, enclosure-hit counted); dead enclosure → failed + persisted
  reason + metadata intact. Fetch/SSRF tests swap the client via
  SetPodcastHTTPClientForTests (the production client refuses
  loopback httptest servers by design — the real client gets its own
  negative test in services).
- TestPodcastScannerExclusion: SetFolders on a podcasts library root
  yields zero scanner folders (invariant 4 pin) — MediaScanner
  .FolderCount() accessor added.

Unit: internal/services/podcast_rss_test.go — namespace-aware parsing
fixture (three XML namespaces, fallback chains, hosts/guests),
duration spellings, RFC-822 variants, explicit variants, slug/path
layout, enclosure-extension selection, stripHTML, and the SSRF matrix
(private/loopback/link-local/multicast/mapped-v6 blocked; a loopback
httptest server PROVES the production client refuses it).

suites green: full integration run 161s / 0 failures; vitest 12 files
163 tests.

Contract widening: TestContractPin_MediaListBothBranches' raw-row
allowlist gains the 15 podcast episode columns (additive; documented in
docs/contract_pins.md — inert defaults on scan rows). Bruno: bruno/
podcasts/ mirrors the §8 endpoints (12 requests) + Bookhoard env vars.
tests/ui/COVERAGE.md rows 18-23: podcasts pages/lifecycle/RLS/chain —
Playwright sweep pending.
2026-10-29 18:09:35 -04:00

561 lines
17 KiB
Go

// 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: "<N> 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
// `<itunes:title>` (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 <rss> 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:
// </rss> — 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 // </channel>
}
}
}
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 <item>.
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 // </item>
}
}
}
// 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 ""
}