Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

429 lines
13 KiB
Go

package player
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/gopxl/beep/v2"
)
// pipeBufSize is the buffer size for audio pipe readers (yt-dlp, ffmpeg).
const pipeBufSize = 64 * 1024
// ytdlPipeTimeout limits how long we wait for yt-dlp to produce initial audio.
const ytdlPipeTimeout = 30 * time.Second
// ytdlCauseGrace bounds how long buildYTDLPipeline waits, after the audio pipe
// closes with no data, for yt-dlp or ffmpeg to report why. yt-dlp typically
// exits quickly with a stderr message (bot wall, 404, DRM); this only matters
// when the process is slow to flush and exit.
const ytdlCauseGrace = 3 * time.Second
// ytdlCookiesFrom is the browser name for --cookies-from-browser (e.g. "chrome").
// Set via SetYTDLCookiesFrom at startup.
var ytdlCookiesFrom string
// SetYTDLCookiesFrom configures yt-dlp to use cookies from the given browser
// for YouTube Music playback (e.g. "chrome", "firefox", "brave").
func SetYTDLCookiesFrom(browser string) {
ytdlCookiesFrom = browser
}
// YTDLPAvailable reports whether yt-dlp is installed and on PATH.
func YTDLPAvailable() bool {
_, err := exec.LookPath("yt-dlp")
return err == nil
}
// probeYTDLDuration runs a quick yt-dlp --print duration to obtain
// the track duration when --flat-playlist didn't provide it.
func probeYTDLDuration(pageURL string) time.Duration {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
args := []string{"--skip-download", "--no-playlist", "--socket-timeout", "10", "--print", "duration"}
if ytdlCookiesFrom != "" {
args = append(args, "--cookies-from-browser", ytdlCookiesFrom)
}
args = append(args, pageURL)
cmd := exec.CommandContext(ctx, "yt-dlp", args...)
// WaitDelay ensures cmd.Output() doesn't hang indefinitely if the
// process is killed but I/O pipe goroutines haven't drained. Without
// this, a zombie yt-dlp child keeping stdout open can block Output()
// forever, which in turn blocks PlayYTDL and leaves the UI stuck at
// "Buffering...".
cmd.WaitDelay = 3 * time.Second
out, err := cmd.Output()
if err != nil {
return 0
}
secs, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil || secs <= 0 {
return 0
}
return time.Duration(secs * float64(time.Second))
}
// InstallYTDLP attempts to install yt-dlp using the system package manager.
// Returns nil on success. The caller should re-check YTDLPAvailable() after.
func InstallYTDLP() error {
switch runtime.GOOS {
case "darwin":
if _, err := exec.LookPath("brew"); err == nil {
cmd := exec.Command("brew", "install", "yt-dlp")
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run()
}
// Fall through to pip
case "linux":
if _, err := exec.LookPath("apt-get"); err == nil {
cmd := exec.Command("sudo", "apt-get", "install", "-y", "yt-dlp")
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run()
}
if _, err := exec.LookPath("pacman"); err == nil {
cmd := exec.Command("sudo", "pacman", "-S", "--noconfirm", "yt-dlp")
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run()
}
}
// Fallback: pip/pipx
if path, err := exec.LookPath("pipx"); err == nil {
cmd := exec.Command(path, "install", "yt-dlp")
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run()
}
if path, err := exec.LookPath("pip3"); err == nil {
cmd := exec.Command(path, "install", "yt-dlp")
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run()
}
return fmt.Errorf("no supported package manager found — install manually: https://github.com/yt-dlp/yt-dlp#installation")
}
// YtdlpInstallHint returns a platform-specific install command suggestion.
func YtdlpInstallHint() string {
switch runtime.GOOS {
case "darwin":
return "brew install yt-dlp"
case "linux":
if _, err := exec.LookPath("apt-get"); err == nil {
return "sudo apt install yt-dlp"
}
if _, err := exec.LookPath("pacman"); err == nil {
return "sudo pacman -S yt-dlp"
}
return "pip install yt-dlp"
case "windows":
return "winget install yt-dlp"
default:
return "pip install yt-dlp"
}
}
// ffmpegInstallHint returns a platform-specific install command suggestion.
func ffmpegInstallHint() string {
switch runtime.GOOS {
case "darwin":
return "brew install ffmpeg"
case "linux":
if _, err := exec.LookPath("apt-get"); err == nil {
return "sudo apt install ffmpeg"
}
if _, err := exec.LookPath("pacman"); err == nil {
return "sudo pacman -S ffmpeg"
}
return "see https://ffmpeg.org/download.html"
case "windows":
return "winget install ffmpeg"
default:
return "see https://ffmpeg.org/download.html"
}
}
// ytdlPipeStreamer streams PCM audio from a yt-dlp | ffmpeg pipe chain.
// yt-dlp downloads the best audio and writes raw data to stdout; ffmpeg reads
// that via a pipe and converts it to PCM on its stdout, which we consume.
type ytdlPipeStreamer struct {
ytdlCmd *exec.Cmd
ffmpegCmd *exec.Cmd
pipe io.ReadCloser // ffmpeg stdout (PCM output)
reader *bufio.Reader // buffered reader over pipe
ytdlErr <-chan error // yt-dlp exit error from monitoring goroutine
ffmpegErr <-chan error // ffmpeg exit error from monitoring goroutine
buf [pcmFrameSize32]byte
f32 bool // true = f32le, false = s16le
pos int // samples consumed so far
closeOnce sync.Once
err error
}
func (y *ytdlPipeStreamer) Stream(samples [][2]float64) (int, bool) {
n, ok := streamFromReader(y.reader, samples, y.buf[:], y.f32, &y.err)
y.pos += n
// On EOF with no frames read, surface why the pipe closed (yt-dlp bot
// wall, 404, DRM, or undecodable ffmpeg input) instead of a bare EOF.
if n == 0 && y.err == nil {
if cause := y.waitCause(0); cause != nil {
y.err = cause
}
}
return n, ok
}
// waitCause reports why the audio pipe closed without producing audio,
// preferring yt-dlp's reason (bot wall, 404, DRM, region block) over ffmpeg's
// (undecodable input). With d <= 0 it polls without blocking; otherwise it
// waits up to d for a process to report. Returns nil if neither reported an
// error, leaving the caller to surface the bare EOF.
func (y *ytdlPipeStreamer) waitCause(d time.Duration) error {
if d <= 0 {
select {
case e := <-y.ytdlErr:
if e != nil {
return e
}
default:
}
select {
case e := <-y.ffmpegErr:
return e
default:
return nil
}
}
deadline := time.After(d)
ytdlDone, ffmpegDone := false, false
var ffErr error
for !ytdlDone || !ffmpegDone {
select {
case e := <-y.ytdlErr:
ytdlDone = true
if e != nil {
return e
}
case e := <-y.ffmpegErr:
ffmpegDone = true
ffErr = e
case <-deadline:
return ffErr
}
}
return ffErr
}
func (y *ytdlPipeStreamer) Err() error { return y.err }
func (y *ytdlPipeStreamer) Len() int { return 0 }
func (y *ytdlPipeStreamer) Position() int { return y.pos }
func (y *ytdlPipeStreamer) Seek(int) error { return nil }
func (y *ytdlPipeStreamer) Close() error {
y.closeOnce.Do(func() {
// Kill both processes to stop downloading/decoding.
if y.ytdlCmd.Process != nil {
y.ytdlCmd.Process.Kill()
}
if y.ffmpegCmd.Process != nil {
y.ffmpegCmd.Process.Kill()
}
y.pipe.Close()
// The yt-dlp and ffmpeg monitor goroutines own Wait() on their
// processes; killing the processes above makes those Waits return.
// Both error channels are buffered, so the monitors send and exit
// without a receiver — no draining needed here.
})
return nil
}
// monitorExit waits for cmd to exit and reports the result on a buffered
// channel: a wrapped error preferring captured stderr over the bare exit code
// on failure, or nil on clean exit. The channel is buffered so the goroutine
// always completes even with no receiver (e.g. after Close kills the process).
func monitorExit(cmd *exec.Cmd, stderr *bytes.Buffer, name string) <-chan error {
ch := make(chan error, 1)
go func() {
err := cmd.Wait()
switch trimmed := bytes.TrimSpace(stderr.Bytes()); {
case err == nil:
ch <- nil
case len(trimmed) > 0:
ch <- fmt.Errorf("%s: %s", name, trimmed)
default:
ch <- fmt.Errorf("%s: %w", name, err)
}
}()
return ch
}
// decodeYTDLPipe starts a yt-dlp | ffmpeg pipe chain for the given page URL
// and returns a streaming PCM decoder. If startSec > 0, ffmpeg -ss is used
// to skip to the desired position in the input stream.
func decodeYTDLPipe(pageURL string, sr beep.SampleRate, bitDepth, startSec int) (*ytdlPipeStreamer, beep.Format, error) {
if _, err := exec.LookPath("yt-dlp"); err != nil {
return nil, beep.Format{}, fmt.Errorf("yt-dlp is required — install: %s", YtdlpInstallHint())
}
if _, err := exec.LookPath("ffmpeg"); err != nil {
return nil, beep.Format{}, fmt.Errorf("ffmpeg is required — install: %s", ffmpegInstallHint())
}
// os.Pipe connects yt-dlp stdout → ffmpeg stdin.
pr, pw, err := os.Pipe()
if err != nil {
return nil, beep.Format{}, fmt.Errorf("os.Pipe: %w", err)
}
// Start yt-dlp: download best audio to stdout.
// Prefer direct HTTPS/HTTP streams over HLS (m3u8). HLS requires segment
// downloading and muxing which doesn't pipe cleanly to stdout.
ytdlArgs := []string{
"-f", "bestaudio[protocol=https]/bestaudio[protocol=http]/bestaudio[protocol!=m3u8_native][protocol!=m3u8]/bestaudio",
"--no-playlist",
"--quiet",
"--no-warnings",
"--socket-timeout", "15",
"-o", "-",
}
if ytdlCookiesFrom != "" {
ytdlArgs = append(ytdlArgs, "--cookies-from-browser", ytdlCookiesFrom)
}
ytdlArgs = append(ytdlArgs, pageURL)
ytdlCmd := exec.Command("yt-dlp", ytdlArgs...)
ytdlCmd.Stdout = pw
var ytdlStderr bytes.Buffer
ytdlCmd.Stderr = &ytdlStderr
if err := ytdlCmd.Start(); err != nil {
pr.Close()
pw.Close()
return nil, beep.Format{}, fmt.Errorf("yt-dlp start: %w", err)
}
// Start ffmpeg: read from pipe, output PCM to stdout.
// If startSec > 0, use -ss to seek into the input stream.
pcmFmt, codec, precision := ffmpegPCMArgs(bitDepth)
var ffmpegArgs []string
if startSec > 0 {
ffmpegArgs = append(ffmpegArgs, "-ss", strconv.Itoa(startSec))
}
ffmpegArgs = append(ffmpegArgs,
"-i", "pipe:0",
"-f", pcmFmt,
"-acodec", codec,
"-ar", strconv.Itoa(int(sr)),
"-ac", "2",
"-loglevel", "error",
"pipe:1",
)
ffmpegCmd := exec.Command("ffmpeg", ffmpegArgs...)
ffmpegCmd.Stdin = pr
var ffmpegStderr bytes.Buffer
ffmpegCmd.Stderr = &ffmpegStderr
ffmpegPipe, err := ffmpegCmd.StdoutPipe()
if err != nil {
pw.Close()
pr.Close()
ytdlCmd.Process.Kill()
ytdlCmd.Wait()
return nil, beep.Format{}, fmt.Errorf("ffmpeg stdout pipe: %w", err)
}
if err := ffmpegCmd.Start(); err != nil {
pw.Close()
pr.Close()
ytdlCmd.Process.Kill()
ytdlCmd.Wait()
return nil, beep.Format{}, fmt.Errorf("ffmpeg start: %w", err)
}
// Close parent's copies of pipe ends. yt-dlp owns pw (write end) and
// ffmpeg owns pr (read end). If the parent keeps these open, EOF won't
// propagate when the owning process exits.
pw.Close()
pr.Close()
// Monitor each process's exit so we can surface why the pipe closed. A
// process's stderr is only safe to read after Wait() returns, so the
// capture happens inside monitorExit.
ytdlErrCh := monitorExit(ytdlCmd, &ytdlStderr, "yt-dlp")
ffmpegErrCh := monitorExit(ffmpegCmd, &ffmpegStderr, "ffmpeg")
format := beep.Format{
SampleRate: sr,
NumChannels: 2,
Precision: precision,
}
return &ytdlPipeStreamer{
ytdlCmd: ytdlCmd,
ffmpegCmd: ffmpegCmd,
pipe: ffmpegPipe,
reader: bufio.NewReaderSize(ffmpegPipe, pipeBufSize),
ytdlErr: ytdlErrCh,
ffmpegErr: ffmpegErrCh,
f32: bitDepth == 32,
}, format, nil
}
// buildYTDLPipeline creates a trackPipeline for a yt-dlp URL.
// If startSec > 0, playback begins at that offset (seek-by-restart).
func (p *Player) buildYTDLPipeline(pageURL string, startSec int) (*trackPipeline, error) {
p.streamTitle.Store("")
decoder, format, err := decodeYTDLPipe(pageURL, p.sr, p.bitDepth, startSec)
if err != nil {
return nil, err
}
// Pre-fill: block until yt-dlp + ffmpeg produce initial audio data.
// This runs in a tea.Cmd goroutine (not the UI thread), ensuring the
// speaker goroutine won't block on an empty pipe and hold its lock
// (which would freeze the UI). A 30s timeout prevents hanging when
// yt-dlp is slow to produce output.
peekErr := make(chan error, 1)
go func() {
_, err := decoder.reader.Peek(1)
peekErr <- err
}()
select {
case err := <-peekErr:
if err != nil {
// The audio pipe closed before producing a byte. Prefer the real
// cause from yt-dlp (e.g. "Sign in to confirm you're not a bot",
// "HTTP Error 404", DRM, region block) or ffmpeg over the opaque
// EOF — the pipe only tells us the upstream closed, not why.
cause := decoder.waitCause(ytdlCauseGrace)
decoder.Close()
if cause != nil {
return nil, cause
}
return nil, fmt.Errorf("waiting for audio data: %w", err)
}
case <-time.After(ytdlPipeTimeout):
decoder.Close()
<-peekErr // drain goroutine after Close() unblocks the pipe
return nil, fmt.Errorf("timed out waiting for audio data (%v)", ytdlPipeTimeout)
}
return &trackPipeline{
decoder: decoder,
stream: decoder,
format: format,
seekable: false,
path: pageURL,
ytdlSeek: true,
streamOffset: time.Duration(startSec) * time.Second,
}, nil
}