rssd/downloader/downloader.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

325 lines
9.1 KiB
Go

package downloader
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"rssd/poller"
"rssd/state"
"rssd/ytdlp"
)
// DownloadJob carries all information needed for a single download.
type DownloadJob struct {
FeedURL string
EnclosureURL string
DestPath string
// YouTube-only fields. IsYouTube selects the yt-dlp download path.
IsYouTube bool
VideoID string // YouTube video ID
Duration float64 // expected video duration in seconds (0 = unknown)
YTSettings ytdlp.Settings
}
// Pool manages a fixed-size worker pool for downloading files.
type Pool struct {
poolSize int
backoffBase time.Duration
state *state.Manager
}
// NewPool creates a download pool with the given parameters.
func NewPool(poolSize int, backoffBase time.Duration, sm *state.Manager) *Pool {
return &Pool{poolSize: poolSize, backoffBase: backoffBase, state: sm}
}
// Run starts the worker goroutines. It returns immediately after spawning the workers.
func (p *Pool) Run(jobs <-chan DownloadJob) {
for i := 0; i < p.poolSize; i++ {
go func() {
for job := range jobs {
p.worker(job)
}
}()
}
}
func (p *Pool) worker(job DownloadJob) {
fmt.Fprintf(os.Stderr, "rssd: worker started download %s\n", job.EnclosureURL)
var err error
if job.IsYouTube {
err = p.downloadYT(job)
} else {
err = p.download(job)
}
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: download %s failed: %v\n", job.EnclosureURL, err)
} else {
fmt.Fprintf(os.Stderr, "OK: downloaded %s -> %s\n", job.EnclosureURL, job.DestPath)
}
fmt.Fprintf(os.Stderr, "rssd: worker finished download %s\n", job.EnclosureURL)
}
// downloadYT downloads the native audio stream of a YouTube video via yt-dlp
// (bestaudio, no -x), i.e. exactly what YouTube serves (typically Opus in
// WebM) with no re-encoding. The file lands at destPath + ".<actual-ext>";
// state is updated to the real path.
func (p *Pool) downloadYT(job DownloadJob) error {
// Update state to downloading.
if !p.state.UpdateJob(job.EnclosureURL, func(j *state.Job) {
j.Status = state.StatusDownloading
j.AttemptCount++
}) {
return fmt.Errorf("job not found for %s", job.EnclosureURL)
}
if err := p.state.Save(); err != nil {
return fmt.Errorf("save state (downloading): %w", err)
}
// Ensure output directory exists.
if err := os.MkdirAll(filepath.Dir(job.DestPath), 0755); err != nil {
p.markRetry(job.EnclosureURL, fmt.Errorf("create dest dir: %w", err))
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
// Download the native best audio stream. yt-dlp writes the file to
// destPath with the source extension (e.g. .webm, .m4a) appended.
if err := p.runYT(ctx, job, "-f", "bestaudio", "-o", job.DestPath+".%(ext)s"); err != nil {
p.markRetry(job.EnclosureURL, err)
return err
}
// Find the actual file: destPath with the real extension appended.
actual, err := findDownloadedFile(job.DestPath)
if err != nil {
p.markRetry(job.EnclosureURL, err)
return err
}
// Duration sanity check: fail fast on videos that only offer short
// clips (e.g. some shorts where only a partial audio stream exists).
if err := checkDuration(actual, job.Duration); err != nil {
os.Remove(actual)
p.markRetry(job.EnclosureURL, err)
return err
}
// Mark as downloaded and record the real destination path (extension
// was only known after the download).
p.state.UpdateJob(job.EnclosureURL, func(j *state.Job) {
j.Status = state.StatusDownloaded
j.DestPath = actual
})
if err := p.state.Save(); err != nil {
return fmt.Errorf("save state (downloaded): %w", err)
}
job.DestPath = actual
return nil
}
// runYT executes yt-dlp for a single video, downloading audio only.
func (p *Pool) runYT(ctx context.Context, job DownloadJob, args ...string) error {
cmd := ytdlp.CommandFor(ctx, job.YTSettings)
cmd.Args = append(cmd.Args, args...)
cmd.Args = append(cmd.Args, "--no-playlist", job.EnclosureURL)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yt-dlp download %s: %v: %s", job.VideoID, err, ytdlp.LastLines(stderr.String(), 3))
}
return nil
}
// findDownloadedFile locates the file yt-dlp produced for the given output
// base path. yt-dlp appends the source extension, so we look for files
// starting with base + "." in the same directory (preferring the most
// recently modified match).
func findDownloadedFile(base string) (string, error) {
dir := filepath.Dir(base)
entries, err := os.ReadDir(dir)
if err != nil {
return "", fmt.Errorf("read output dir: %w", err)
}
var best string
var bestMod time.Time
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if name == filepath.Base(base) {
return filepath.Join(dir, name), nil
}
if strings.HasPrefix(name, filepath.Base(base)+".") {
if best == "" {
best = filepath.Join(dir, name)
info, err := e.Info()
if err == nil {
bestMod = info.ModTime()
}
continue
}
info, err := e.Info()
if err == nil && info.ModTime().After(bestMod) {
best = filepath.Join(dir, name)
bestMod = info.ModTime()
}
}
}
if best == "" {
return "", fmt.Errorf("downloaded file not found for %s", base)
}
return best, nil
}
// checkDuration verifies the extracted audio's duration is within tolerance
// of the expected video duration. This catches videos where yt-dlp fell back
// to a shorter clip (e.g. some shorts only offer partial audio).
func checkDuration(path string, expected float64) error {
if expected <= 0 {
return nil // no expectation to check against
}
act, err := ffprobeDuration(path)
if err != nil {
// If we can't probe, don't block the download.
return nil
}
// Allow a 10% tolerance plus a 5s floor for rounding/formatting.
tol := expected * 0.10
if tol < 5 {
tol = 5
}
if math.Abs(act-expected) > tol {
return fmt.Errorf("duration mismatch: extracted %.0fs vs expected %.0fs (video may be unavailable in full length)", act, expected)
}
return nil
}
func (p *Pool) download(job DownloadJob) error {
// Update state to downloading.
if !p.state.UpdateJob(job.EnclosureURL, func(j *state.Job) {
j.Status = state.StatusDownloading
j.AttemptCount++
}) {
return fmt.Errorf("job not found for %s", job.EnclosureURL)
}
if err := p.state.Save(); err != nil {
return fmt.Errorf("save state (downloading): %w", err)
}
// Ensure output directory exists.
if err := os.MkdirAll(filepath.Dir(job.DestPath), 0755); err != nil {
p.markRetry(job.EnclosureURL, fmt.Errorf("create dest dir: %w", err))
return err
}
// Create destination file.
dstFile, err := os.Create(job.DestPath)
if err != nil {
p.markRetry(job.EnclosureURL, fmt.Errorf("create dest file: %w", err))
return err
}
// Download with timeout.
client := &http.Client{Timeout: 30 * time.Minute}
req, err := http.NewRequest("GET", job.EnclosureURL, nil)
if err != nil {
dstFile.Close()
os.Remove(job.DestPath)
p.markRetry(job.EnclosureURL, fmt.Errorf("create request: %w", err))
return err
}
req.Header.Set("User-Agent", poller.UserAgent)
resp, err := client.Do(req)
if err != nil {
dstFile.Close()
os.Remove(job.DestPath)
p.markRetry(job.EnclosureURL, fmt.Errorf("fetch: %w", err))
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
dstFile.Close()
os.Remove(job.DestPath)
p.markRetry(job.EnclosureURL, fmt.Errorf("HTTP %d", resp.StatusCode))
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
// Copy body to file.
if _, err := io.Copy(dstFile, resp.Body); err != nil {
dstFile.Close()
os.Remove(job.DestPath)
p.markRetry(job.EnclosureURL, fmt.Errorf("write file: %w", err))
return err
}
if err := dstFile.Close(); err != nil {
os.Remove(job.DestPath)
p.markRetry(job.EnclosureURL, fmt.Errorf("close file: %w", err))
return err
}
// Mark as downloaded.
p.state.UpdateJob(job.EnclosureURL, func(j *state.Job) {
j.Status = state.StatusDownloaded
})
if err := p.state.Save(); err != nil {
return fmt.Errorf("save state (downloaded): %w", err)
}
return nil
}
// ffprobeDuration returns the duration of a media file in seconds.
func ffprobeDuration(path string) (float64, error) {
out, err := exec.Command("ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "json", path).Output()
if err != nil {
return 0, fmt.Errorf("ffprobe %s: %w", filepath.Base(path), err)
}
var res struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
if err := json.Unmarshal(out, &res); err != nil {
return 0, err
}
var d float64
if _, err := fmt.Sscanf(res.Format.Duration, "%g", &d); err != nil {
return 0, err
}
return d, nil
}
// markRetry updates a job to retrying status with the given error and computes backoff delay.
func (p *Pool) markRetry(enclosureURL string, err error) {
p.state.UpdateJob(enclosureURL, func(j *state.Job) {
j.Status = state.StatusRetrying
j.Error = err.Error()
// Exponential backoff: base * 2^attemptCount (attempt already incremented).
delay := p.backoffBase * time.Duration(1<<uint(j.AttemptCount-1))
t := time.Now().Add(delay)
j.NextAttemptAt = &t
})
p.state.Save()
}