| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 |
- // ytdlp_service.go — yt-dlp 视频下载的 Wails 服务。
- //
- // 本服务自动在系统 PATH 中查找 yt-dlp(exec.LookPath),找到才允许前端展示
- // 「视频下载」工具卡片;下载保存目录保存在设置中(AppSettings.YTDLP)。
- // 下载过程通过 ytdlp:log / ytdlp:exit 事件把日志与退出状态推送给前端。
- package main
- import (
- "context"
- "errors"
- "fmt"
- "os/exec"
- "strings"
- "sync"
- "time"
- )
- // YTDLPConfig 是「视频下载」的配置。
- type YTDLPConfig struct {
- Dir string `json:"dir"` // 下载保存目录,空=程序当前工作目录
- Mp3Quality string `json:"mp3Quality"` // mp3 转换质量:0-9(VBR,0 最好)或 128K/192K/320K,默认 0
- }
- // validMp3Quality 校验 mp3 转换质量参数:0-9(VBR,0 最好)或常见码率。
- func validMp3Quality(q string) bool {
- switch strings.ToUpper(q) {
- case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "128K", "192K", "320K":
- return true
- }
- return false
- }
- // YTDLPInfo 描述本机 yt-dlp 的可用性,用于前端决定是否展示工具卡片。
- type YTDLPInfo struct {
- Available bool `json:"available"` // PATH 中是否找到 yt-dlp
- Path string `json:"path"` // yt-dlp 可执行文件路径
- Version string `json:"version"` // 版本信息(yt-dlp --version 输出)
- FFmpeg bool `json:"ffmpeg"` // PATH 中是否找到 ffmpeg(用于「下载转 mp3」)
- }
- // YTDLPService 是「视频下载」功能的 Wails 服务。
- type YTDLPService struct {
- mu sync.Mutex
- cancel context.CancelFunc
- }
- // NewYTDLPService 创建 yt-dlp 服务实例。
- func NewYTDLPService() *YTDLPService {
- return &YTDLPService{}
- }
- // Available 检测 PATH 中是否存在 yt-dlp,并返回其路径与版本。
- func (s *YTDLPService) Available() YTDLPInfo {
- path, err := exec.LookPath("yt-dlp")
- if err != nil || path == "" {
- return YTDLPInfo{}
- }
- info := YTDLPInfo{Available: true, Path: path}
- if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil && ffmpegPath != "" {
- info.FFmpeg = true // 有 ffmpeg 才能转 mp3
- }
- ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
- defer cancel()
- cmd := exec.CommandContext(ctx, path, "--version")
- hideProcessWindow(cmd)
- if out, err := cmd.Output(); err == nil {
- info.Version = strings.TrimSpace(string(out))
- }
- return info
- }
- // GetConfig 读取当前下载配置(每次从设置文件读取,保证与设置页保存结果一致)。
- func (s *YTDLPService) GetConfig() YTDLPConfig {
- return loadSettings().YTDLP
- }
- // StartDownload 开始下载视频:yt-dlp --newline [-P <目录>] [-x --audio-format mp3] -o "%(title)s.%(ext)s" <url>。
- // audioOnly 为 true 时下载并转换为 mp3(需要 ffmpeg)。
- // 日志与退出状态分别通过 ytdlp:log / ytdlp:exit 事件推送。
- func (s *YTDLPService) StartDownload(url string, audioOnly bool) error {
- url = strings.TrimSpace(url)
- if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
- return errors.New("下载地址无效")
- }
- path, err := exec.LookPath("yt-dlp")
- if err != nil {
- return errors.New("未找到 yt-dlp,请先安装并加入系统 PATH")
- }
- if audioOnly {
- if _, err := exec.LookPath("ffmpeg"); err != nil {
- return errors.New("未找到 ffmpeg,无法转换为 mp3")
- }
- }
- s.mu.Lock()
- if s.cancel != nil {
- s.mu.Unlock()
- return errors.New("已有下载任务正在进行")
- }
- ctx, cancel := context.WithCancel(context.Background())
- s.cancel = cancel
- args := []string{"--newline", "--encoding", "utf-8"} // 强制 UTF-8 输出,避免 Windows 下标题/进度按 GBK 输出导致乱码
- if audioOnly {
- // 提取音频并转 mp3,质量取设置中的值(0-9 VBR 或 128K/192K/320K)
- quality := strings.TrimSpace(s.GetConfig().Mp3Quality)
- if quality == "" {
- quality = "0"
- }
- args = append(args, "-x", "--audio-format", "mp3", "--audio-quality", quality)
- }
- if dir := strings.TrimSpace(s.GetConfig().Dir); dir != "" {
- args = append(args, "-P", dir) // 下载保存目录(设置中配置)
- }
- args = append(args, "-o", "%(title)s.%(ext)s", url)
- cmd := exec.CommandContext(ctx, path, args...)
- hideProcessWindow(cmd)
- if err := s.streamProcess(cmd); err != nil {
- s.cancel = nil
- cancel()
- s.mu.Unlock()
- return fmt.Errorf("启动下载失败:%w", err)
- }
- s.mu.Unlock()
- return nil
- }
- // StopDownload 终止下载任务(若在运行)。
- func (s *YTDLPService) StopDownload() {
- s.mu.Lock()
- cancel := s.cancel
- s.mu.Unlock()
- if cancel != nil {
- cancel()
- }
- }
- // FetchTitle 用 yt-dlp 解析视频标题(不下载),供工具列表显示。
- // 解析失败(网络/链接失效等)返回空字符串,由前端回退显示链接。
- func (s *YTDLPService) FetchTitle(url string) string {
- url = strings.TrimSpace(url)
- if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
- return ""
- }
- path, err := exec.LookPath("yt-dlp")
- if err != nil {
- return ""
- }
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- cmd := exec.CommandContext(ctx, path, "--encoding", "utf-8", "--no-warnings", "--skip-download", "--get-title", url)
- hideProcessWindow(cmd)
- out, err := cmd.Output()
- if err != nil {
- return ""
- }
- title := strings.TrimSpace(string(out))
- if i := strings.IndexAny(title, "\r\n"); i >= 0 {
- title = strings.TrimSpace(title[:i]) // 只取第一行
- }
- return title
- }
- // streamProcess 启动进程并把输出逐行推送给前端。调用方需持有 s.mu。
- func (s *YTDLPService) streamProcess(cmd *exec.Cmd) error {
- stdout, err := cmd.StdoutPipe()
- if err != nil {
- return err
- }
- stderr, err := cmd.StderrPipe()
- if err != nil {
- return err
- }
- if err := cmd.Start(); err != nil {
- return err
- }
- emit := func(raw string) {
- line := strings.TrimSpace(crocAnsiRe.ReplaceAllString(raw, ""))
- if line == "" {
- return
- }
- app.Event.Emit("ytdlp:log", map[string]string{"line": line})
- }
- go scanCrocStream(stdout, emit)
- go scanCrocStream(stderr, emit)
- go func() {
- waitErr := cmd.Wait()
- exitCode := 0
- if waitErr != nil {
- var ee *exec.ExitError
- if errors.As(waitErr, &ee) {
- exitCode = ee.ExitCode()
- } else {
- exitCode = -1
- }
- }
- s.mu.Lock()
- s.cancel = nil
- s.mu.Unlock()
- app.Event.Emit("ytdlp:exit", map[string]any{"code": exitCode})
- }()
- return nil
- }
|