// 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" 。 // 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 }