rssd/ytdlp/ytdlp.go
Greg Pomerantz c562ecc411 Schedule upcoming streams at their start time; fix is_upcoming check
yt-dlp reports scheduled premieres as live_status 'is_upcoming' (not
'upcoming'), and exposes release_timestamp with the stream's scheduled
start time. For an upcoming stream with a known start, reschedule the
job at start+2min without touching the wait counter, no matter how far
away the start is — the bounded 10-minute wait clock (maxLiveWaits,
~4h) now only applies while a stream is actually running. Upcoming
streams with an unknown start time keep the 10-minute wait behavior.
2026-09-28 17:54:25 -04:00

198 lines
5.8 KiB
Go

// Package ytdlp provides a thin wrapper around the yt-dlp CLI for
// YouTube channel/playlist discovery and per-video metadata resolution.
package ytdlp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// maxJSONLineBytes bounds one line of --print-json output. A full video's
// JSON (including the formats list) routinely exceeds 1 MB for long videos,
// so the limit must be generous.
const maxJSONLineBytes = 16 * 1024 * 1024
// Settings controls how yt-dlp is invoked.
type Settings struct {
// BinPath is the path to the yt-dlp executable (or a name on $PATH).
BinPath string
// PlayerClient is passed via --extractor-args "youtube:player_client=..."
// to work around YouTube client restrictions (see DefaultPlayerClient).
PlayerClient string
}
// DefaultPlayerClient works around YouTube's SABR-only streaming experiment,
// which withholds plain HTTPS URLs from the default web/android clients.
const DefaultPlayerClient = "web_embedded"
// FlatVideo is one entry from a --flat-playlist enumeration.
type FlatVideo struct {
ID string `json:"id"`
Title string `json:"title"`
Duration *float64 `json:"duration"` // seconds; may be nil (e.g. some shorts)
Channel string `json:"channel"`
}
// Discovery enumerates all videos in a YouTube channel or playlist using
// `yt-dlp --flat-playlist` (fast; does not resolve each video).
// Returns videos in the order yt-dlp emits them:
//
// - channel: newest first
// - playlist: playlist order (not necessarily chronological)
func Discovery(ctx context.Context, s Settings, sourceURL string) ([]FlatVideo, error) {
args := []string{"--flat-playlist", "--print-json", sourceURL}
stdout, stderr, err := run(ctx, s, args...)
if err != nil {
return nil, fmt.Errorf("yt-dlp discovery %s: %v: %s", sourceURL, err, LastLines(string(stderr), 3))
}
var vids []FlatVideo
sc := bufio.NewScanner(bytes.NewReader(stdout))
sc.Buffer(make([]byte, 0, 64*1024), maxJSONLineBytes)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var v FlatVideo
if err := json.Unmarshal([]byte(line), &v); err != nil {
continue // skip malformed lines
}
if v.ID == "" {
continue
}
vids = append(vids, v)
}
if len(vids) == 0 {
return nil, fmt.Errorf("yt-dlp discovery %s: no videos found", sourceURL)
}
return vids, nil
}
// VideoInfo carries resolved metadata for a single video.
type VideoInfo struct {
ID string
Title string
Duration float64 // seconds, 0 if unknown
UploadDate time.Time
LiveStatus string // "not_live", "is_live", "was_live", "is_upcoming", or "" if unknown
// ReleaseTimestamp is the scheduled start time of a live stream in unix
// seconds (from liveBroadcastDetails.startTimestamp); 0 if unknown.
ReleaseTimestamp int64
}
// Info resolves full metadata for a single video by ID.
func Info(ctx context.Context, s Settings, videoID string) (VideoInfo, error) {
url := "https://www.youtube.com/watch?v=" + videoID
stdout, stderr, err := run(ctx, s,
"--no-download", "--no-playlist", "--print-json", url)
if err != nil {
return VideoInfo{}, fmt.Errorf("yt-dlp info %s: %v: %s", videoID, err, LastLines(string(stderr), 3))
}
sc := bufio.NewScanner(bytes.NewReader(stdout))
sc.Buffer(make([]byte, 0, 64*1024), maxJSONLineBytes)
var data map[string]any
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
if err := json.Unmarshal([]byte(line), &data); err == nil {
break
}
}
if data == nil {
if err := sc.Err(); err != nil {
return VideoInfo{}, fmt.Errorf("yt-dlp info %s: %v", videoID, err)
}
return VideoInfo{}, fmt.Errorf("yt-dlp info %s: empty or unparseable output", videoID)
}
info := VideoInfo{ID: videoID}
if t, _ := data["title"].(string); t != "" {
info.Title = t
}
if d, ok := num(data["duration"]); ok && d > 0 {
info.Duration = d
}
// upload_date is "YYYYMMDD" or "NA" (or null).
if ud, ok := data["upload_date"].(string); ok && ud != "" && ud != "NA" {
if t, err := time.Parse("20060102", ud); err == nil {
info.UploadDate = t
}
}
info.LiveStatus, _ = data["live_status"].(string)
if ts, ok := num(data["release_timestamp"]); ok {
info.ReleaseTimestamp = int64(ts)
}
return info, nil
}
// CommandFor builds an *exec.Cmd running yt-dlp with the standard
// arguments for the given settings (binary + YouTube extractor args).
// Additional arguments may be appended to cmd.Args by the caller.
func CommandFor(ctx context.Context, s Settings) *exec.Cmd {
bin := s.BinPath
if bin == "" {
bin = "yt-dlp"
}
pc := s.PlayerClient
if pc == "" {
pc = DefaultPlayerClient
}
return exec.CommandContext(ctx, bin,
"--no-warnings", "--extractor-args", "youtube:player_client="+pc)
}
// run executes yt-dlp with the given arguments and returns stdout and stderr.
func run(ctx context.Context, s Settings, args ...string) ([]byte, []byte, error) {
cmd := CommandFor(ctx, s)
cmd.Args = append(cmd.Args, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return stdout.Bytes(), stderr.Bytes(), err
}
return stdout.Bytes(), stderr.Bytes(), nil
}
// LastLines returns the last n non-empty lines of s, joined with " | ".
// Useful for compact error messages from yt-dlp output.
func LastLines(s string, n int) string {
var lines []string
for _, l := range strings.Split(s, "\n") {
if t := strings.TrimSpace(l); t != "" {
lines = append(lines, t)
}
}
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, " | ")
}
func num(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int:
return float64(x), true
case int64:
return float64(x), true
case json.Number:
f, err := x.Float64()
return f, err == nil
}
return 0, false
}