rssd/poller/poller.go
Greg Pomerantz bc26e73a90 Add YouTube feed support via yt-dlp, MIME-based extensions, robust state
- ytdlp: thin wrapper around yt-dlp CLI for channel/playlist discovery
  (--flat-playlist) and per-video metadata (--print-json); defaults to
  player_client=web_embedded to avoid SABR-only format restrictions.
- downloader: YouTube path extracts audio via yt-dlp -x, probes duration
  with ffprobe and rejects clips far shorter than the expected length.
- main: YouTube channel/playlist feeds, incremental discovery with
  pre-start-date boundary for newest-first channels, two-phase
  record-then-enqueue so discovered jobs survive crashes.
- poller: propagate enclosure MIME type; computeDestPath appends the
  matching file extension.
- state: jobs carry expected duration; AddJobWithStatus for skipped items.
- cmd/probe: dry-run discovery/date-resolution diagnostics.
2026-09-23 09:06:36 -04:00

130 lines
3.2 KiB
Go

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
}