- 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.
130 lines
3.2 KiB
Go
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
|
|
}
|