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.
198 lines
5.8 KiB
Go
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
|
|
}
|