429 lines
13 KiB
Go
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
|
|
}
|