jim800121chen c0317225ca feat(local-tool): yt-dlp 錯誤訊息友善化 + 載入逾時提示
ResolveWithYTDLP 改善:
- 新增 friendlyYTDLPError() 把 yt-dlp 的技術性 stderr 訊息轉成繁中提示
- 涵蓋 9 種常見失敗原因:
  年齡限制 / 私人影片 / 已移除 / 版權 / 直播 / 首播 / DRM / 頻率限制 / 無格式
- 錯誤訊息同時附上原始 stderr(方便 debug)
- yt-dlp 未安裝時改用繁中提示

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:56:15 +08:00

304 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package camera
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"sync/atomic"
)
// VideoInfo holds metadata extracted by ffprobe before pipeline starts.
type VideoInfo struct {
DurationSec float64 // total duration in seconds
TotalFrames int // estimated total frames at target FPS
}
// ProbeVideoInfo runs ffprobe to extract duration from a video file or URL.
// Returns zero values (no error) when duration is indeterminate (e.g. live streams).
func ProbeVideoInfo(input string, fps float64) VideoInfo {
cmd := exec.Command("ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "format=duration",
"-of", "csv=p=0",
input,
)
out, err := cmd.Output()
if err != nil {
return VideoInfo{}
}
durStr := strings.TrimSpace(string(out))
if durStr == "" || durStr == "N/A" {
return VideoInfo{}
}
dur, err := strconv.ParseFloat(durStr, 64)
if err != nil {
return VideoInfo{}
}
if fps <= 0 {
fps = 15
}
return VideoInfo{
DurationSec: dur,
TotalFrames: int(dur * fps),
}
}
// VideoSource reads a video file or URL frame-by-frame using ffmpeg, outputting
// JPEG frames via stdout. Reuses the same JPEG SOI/EOI marker parsing
// pattern as FFmpegCamera.
type VideoSource struct {
cmd *exec.Cmd
stdout io.ReadCloser
frameCh chan []byte // decoded frames queue
mu sync.Mutex
done chan struct{}
finished bool
err error
filePath string // local file path (empty for URL sources)
isURL bool // true when source is a URL, skip file cleanup
totalFrames int64 // 0 means unknown
frameCount int64 // atomic counter incremented in readLoop
}
// NewVideoSource starts an ffmpeg process that decodes a video file
// and outputs MJPEG frames to stdout at the specified FPS.
func NewVideoSource(filePath string, fps float64) (*VideoSource, error) {
return newVideoSource(filePath, fps, false, 0)
}
// NewVideoSourceWithSeek starts ffmpeg from a specific position (in seconds).
func NewVideoSourceWithSeek(filePath string, fps float64, seekSeconds float64) (*VideoSource, error) {
return newVideoSource(filePath, fps, false, seekSeconds)
}
// NewVideoSourceFromURL starts an ffmpeg process that reads from a URL
// (HTTP, HTTPS, RTSP, etc.) and outputs MJPEG frames to stdout.
func NewVideoSourceFromURL(rawURL string, fps float64) (*VideoSource, error) {
return newVideoSource(rawURL, fps, true, 0)
}
// NewVideoSourceFromURLWithSeek starts ffmpeg from a URL at a specific position.
func NewVideoSourceFromURLWithSeek(rawURL string, fps float64, seekSeconds float64) (*VideoSource, error) {
return newVideoSource(rawURL, fps, true, seekSeconds)
}
// ResolveWithYTDLP uses yt-dlp to extract the direct video stream URL
// from platforms like YouTube, Vimeo, etc.
// Returns the resolved direct URL or an error.
func ResolveWithYTDLP(rawURL string) (string, error) {
cmd := exec.Command("yt-dlp", "-f", "best[ext=mp4]/best", "--get-url", rawURL)
out, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := strings.TrimSpace(string(exitErr.Stderr))
return "", fmt.Errorf("%s\n%s", friendlyYTDLPError(stderr), stderr)
}
return "", fmt.Errorf("yt-dlp 未安裝或無法執行: %w", err)
}
resolved := strings.TrimSpace(string(out))
if resolved == "" {
return "", fmt.Errorf("yt-dlp 無法取得影片下載連結(影片可能有地區限制或需要登入)")
}
if idx := strings.Index(resolved, "\n"); idx > 0 {
resolved = resolved[:idx]
}
return resolved, nil
}
// friendlyYTDLPError 把 yt-dlp 的技術性錯誤訊息轉成使用者能理解的提示。
func friendlyYTDLPError(stderr string) string {
s := strings.ToLower(stderr)
switch {
case strings.Contains(s, "sign in") || strings.Contains(s, "age"):
return "此影片需要登入 YouTube 帳號才能觀看(例如年齡限制),無法直接使用"
case strings.Contains(s, "private"):
return "此影片為私人影片,無法存取"
case strings.Contains(s, "unavailable") || strings.Contains(s, "not available"):
return "此影片無法取得(可能已被移除或在你的地區不可用)"
case strings.Contains(s, "copyright"):
return "此影片因版權限制無法下載"
case strings.Contains(s, "live"):
return "此影片為直播串流,目前不支援直播推論"
case strings.Contains(s, "premiere"):
return "此影片為首播Premiere尚未開始或格式不支援"
case strings.Contains(s, "drm") || strings.Contains(s, "protected"):
return "此影片有 DRM 保護,無法下載"
case strings.Contains(s, "rate limit") || strings.Contains(s, "429"):
return "YouTube 暫時限制了請求頻率,請稍後再試"
case strings.Contains(s, "no video formats"):
return "找不到可用的影片格式"
default:
return "無法解析此影片連結,請確認 URL 是否正確且影片為公開狀態"
}
}
func newVideoSource(input string, fps float64, isURL bool, seekSeconds float64) (*VideoSource, error) {
if fps <= 0 {
fps = 15
}
args := []string{}
if seekSeconds > 0 {
args = append(args, "-ss", fmt.Sprintf("%.3f", seekSeconds))
}
args = append(args,
"-i", input,
"-vf", fmt.Sprintf("fps=%g", fps),
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "5",
"-an",
"-",
)
cmd := exec.Command("ffmpeg", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
}
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
}
filePath := ""
if !isURL {
filePath = input
}
vs := &VideoSource{
cmd: cmd,
stdout: stdout,
frameCh: make(chan []byte, 30), // buffer up to 30 frames
done: make(chan struct{}),
filePath: filePath,
isURL: isURL,
}
go vs.readLoop()
return vs, nil
}
// readLoop scans ffmpeg stdout for JPEG SOI/EOI markers to extract frames.
func (v *VideoSource) readLoop() {
defer close(v.done)
defer close(v.frameCh)
reader := bufio.NewReaderSize(v.stdout, 1024*1024)
buf := make([]byte, 0, 512*1024)
inFrame := false
for {
b, err := reader.ReadByte()
if err != nil {
v.mu.Lock()
v.finished = true
if err != io.EOF {
v.err = fmt.Errorf("ffmpeg stream ended: %w", err)
}
v.mu.Unlock()
return
}
if !inFrame {
if b == 0xFF {
next, err := reader.ReadByte()
if err != nil {
v.mu.Lock()
v.finished = true
v.mu.Unlock()
return
}
if next == 0xD8 {
buf = buf[:0]
buf = append(buf, 0xFF, 0xD8)
inFrame = true
}
}
continue
}
buf = append(buf, b)
if b == 0xD9 && len(buf) >= 2 && buf[len(buf)-2] == 0xFF {
frame := make([]byte, len(buf))
copy(frame, buf)
v.frameCh <- frame // blocks if buffer full, applies backpressure
atomic.AddInt64(&v.frameCount, 1)
inFrame = false
}
}
}
// ReadFrame returns the next decoded frame, blocking until one is available.
// Returns an error when all frames have been consumed and ffmpeg has finished.
func (v *VideoSource) ReadFrame() ([]byte, error) {
frame, ok := <-v.frameCh
if !ok {
return nil, fmt.Errorf("video playback complete")
}
return frame, nil
}
// SetTotalFrames sets the expected total frame count (from ffprobe).
func (v *VideoSource) SetTotalFrames(n int) {
atomic.StoreInt64(&v.totalFrames, int64(n))
}
// TotalFrames returns the expected total frame count, or 0 if unknown.
func (v *VideoSource) TotalFrames() int {
return int(atomic.LoadInt64(&v.totalFrames))
}
// FrameCount returns the number of frames decoded so far.
func (v *VideoSource) FrameCount() int {
return int(atomic.LoadInt64(&v.frameCount))
}
// IsFinished returns true when the video file has been fully decoded
// AND all buffered frames have been consumed.
func (v *VideoSource) IsFinished() bool {
v.mu.Lock()
finished := v.finished
v.mu.Unlock()
return finished && len(v.frameCh) == 0
}
// CloseWithoutRemove stops the ffmpeg process but does NOT delete the temp file.
// Used when seeking: we need to restart ffmpeg from a different position but keep the file.
func (v *VideoSource) CloseWithoutRemove() error {
if v.cmd != nil && v.cmd.Process != nil {
_ = v.cmd.Process.Kill()
_ = v.cmd.Wait()
}
for range v.frameCh {
}
<-v.done
return nil
}
func (v *VideoSource) Close() error {
if v.cmd != nil && v.cmd.Process != nil {
_ = v.cmd.Process.Kill()
_ = v.cmd.Wait()
}
// Drain any remaining frames so readLoop can exit
for range v.frameCh {
}
<-v.done
// Only remove temp files, not URL sources
if !v.isURL && v.filePath != "" {
_ = os.Remove(v.filePath)
}
return nil
}