package poller import ( "fmt" "io" "net/http" "time" "github.com/mmcdole/gofeed" ) // EnclosureInfo carries an enclosure URL and its associated item's metadata. type EnclosureInfo struct { URL string Title string // episode title, if available MimeType string // MIME type from the enclosure (e.g. "audio/mpeg") PublishedAt time.Time // zero value means no date available } // FeedResult holds the result of polling a single feed. type FeedResult struct { URL string NewEnclosures []EnclosureInfo LastModified string ETag string NotModified bool Error error } // UserAgent is sent on all outbound HTTP requests (feed fetches and // enclosure downloads). Some hosts (e.g. Buzzsprout) reject Go's default // "Go-http-client" UA with 403. const UserAgent = "Mozilla/5.0 (compatible; rssd/1.0; +https://github.com/rssd)" // Fetcher abstracts the HTTP fetch + parse cycle for a single feed. type Fetcher struct { httpClient *http.Client parser *gofeed.Parser } // NewFetcher creates a new Fetcher with sensible defaults. func NewFetcher() *Fetcher { return &Fetcher{ httpClient: &http.Client{ Timeout: 30 * time.Second, }, parser: gofeed.NewParser(), } } // Poll fetches and parses an RSS/Atom feed, returning new enclosure info. // lastModified and etag are used for conditional requests (If-None-Match / If-Modified-Since). // Returns a FeedResult with NewEnclosures populated only if the feed changed. func (f *Fetcher) Poll(url, lastModified, etag string) (*FeedResult, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("User-Agent", UserAgent) if etag != "" { req.Header.Set("If-None-Match", etag) } if lastModified != "" { req.Header.Set("If-Modified-Since", lastModified) } resp, err := f.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("fetch %s: %w", url, err) } defer resp.Body.Close() // Handle 304 Not Modified — feed hasn't changed. if resp.StatusCode == http.StatusNotModified { return &FeedResult{ URL: url, NotModified: true, }, nil } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetch %s: HTTP %d", url, resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read body %s: %w", url, err) } feed, err := f.parser.ParseString(string(body)) if err != nil { return nil, fmt.Errorf("parse feed %s: %w", url, err) } // Extract enclosure info from feed items. var enclosures []EnclosureInfo for _, item := range feed.Items { for _, enc := range item.Enclosures { if enc.URL != "" { encInfo := EnclosureInfo{ URL: enc.URL, Title: item.Title, MimeType: enc.Type, } if item.PublishedParsed != nil { encInfo.PublishedAt = *item.PublishedParsed } enclosures = append(enclosures, encInfo) } } } result := &FeedResult{ URL: url, NewEnclosures: enclosures, NotModified: false, } // Capture cache headers for the next conditional request. if v := resp.Header.Get("Last-Modified"); v != "" { result.LastModified = v } if v := resp.Header.Get("ETag"); v != "" { result.ETag = v } return result, nil }