ytdlp_service.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. // ytdlp_service.go — yt-dlp 视频下载的 Wails 服务。
  2. //
  3. // 本服务自动在系统 PATH 中查找 yt-dlp(exec.LookPath),找到才允许前端展示
  4. // 「视频下载」工具卡片;下载保存目录保存在设置中(AppSettings.YTDLP)。
  5. // 下载过程通过 ytdlp:log / ytdlp:exit 事件把日志与退出状态推送给前端。
  6. package main
  7. import (
  8. "context"
  9. "errors"
  10. "fmt"
  11. "os/exec"
  12. "strings"
  13. "sync"
  14. "time"
  15. )
  16. // YTDLPConfig 是「视频下载」的配置。
  17. type YTDLPConfig struct {
  18. Dir string `json:"dir"` // 下载保存目录,空=程序当前工作目录
  19. Mp3Quality string `json:"mp3Quality"` // mp3 转换质量:0-9(VBR,0 最好)或 128K/192K/320K,默认 0
  20. }
  21. // validMp3Quality 校验 mp3 转换质量参数:0-9(VBR,0 最好)或常见码率。
  22. func validMp3Quality(q string) bool {
  23. switch strings.ToUpper(q) {
  24. case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "128K", "192K", "320K":
  25. return true
  26. }
  27. return false
  28. }
  29. // YTDLPInfo 描述本机 yt-dlp 的可用性,用于前端决定是否展示工具卡片。
  30. type YTDLPInfo struct {
  31. Available bool `json:"available"` // PATH 中是否找到 yt-dlp
  32. Path string `json:"path"` // yt-dlp 可执行文件路径
  33. Version string `json:"version"` // 版本信息(yt-dlp --version 输出)
  34. FFmpeg bool `json:"ffmpeg"` // PATH 中是否找到 ffmpeg(用于「下载转 mp3」)
  35. }
  36. // YTDLPService 是「视频下载」功能的 Wails 服务。
  37. type YTDLPService struct {
  38. mu sync.Mutex
  39. cancel context.CancelFunc
  40. }
  41. // NewYTDLPService 创建 yt-dlp 服务实例。
  42. func NewYTDLPService() *YTDLPService {
  43. return &YTDLPService{}
  44. }
  45. // Available 检测 PATH 中是否存在 yt-dlp,并返回其路径与版本。
  46. func (s *YTDLPService) Available() YTDLPInfo {
  47. path, err := exec.LookPath("yt-dlp")
  48. if err != nil || path == "" {
  49. return YTDLPInfo{}
  50. }
  51. info := YTDLPInfo{Available: true, Path: path}
  52. if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil && ffmpegPath != "" {
  53. info.FFmpeg = true // 有 ffmpeg 才能转 mp3
  54. }
  55. ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
  56. defer cancel()
  57. cmd := exec.CommandContext(ctx, path, "--version")
  58. hideProcessWindow(cmd)
  59. if out, err := cmd.Output(); err == nil {
  60. info.Version = strings.TrimSpace(string(out))
  61. }
  62. return info
  63. }
  64. // GetConfig 读取当前下载配置(每次从设置文件读取,保证与设置页保存结果一致)。
  65. func (s *YTDLPService) GetConfig() YTDLPConfig {
  66. return loadSettings().YTDLP
  67. }
  68. // StartDownload 开始下载视频:yt-dlp --newline [-P <目录>] [-x --audio-format mp3] -o "%(title)s.%(ext)s" <url>。
  69. // audioOnly 为 true 时下载并转换为 mp3(需要 ffmpeg)。
  70. // 日志与退出状态分别通过 ytdlp:log / ytdlp:exit 事件推送。
  71. func (s *YTDLPService) StartDownload(url string, audioOnly bool) error {
  72. url = strings.TrimSpace(url)
  73. if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
  74. return errors.New("下载地址无效")
  75. }
  76. path, err := exec.LookPath("yt-dlp")
  77. if err != nil {
  78. return errors.New("未找到 yt-dlp,请先安装并加入系统 PATH")
  79. }
  80. if audioOnly {
  81. if _, err := exec.LookPath("ffmpeg"); err != nil {
  82. return errors.New("未找到 ffmpeg,无法转换为 mp3")
  83. }
  84. }
  85. s.mu.Lock()
  86. if s.cancel != nil {
  87. s.mu.Unlock()
  88. return errors.New("已有下载任务正在进行")
  89. }
  90. ctx, cancel := context.WithCancel(context.Background())
  91. s.cancel = cancel
  92. args := []string{"--newline", "--encoding", "utf-8"} // 强制 UTF-8 输出,避免 Windows 下标题/进度按 GBK 输出导致乱码
  93. if audioOnly {
  94. // 提取音频并转 mp3,质量取设置中的值(0-9 VBR 或 128K/192K/320K)
  95. quality := strings.TrimSpace(s.GetConfig().Mp3Quality)
  96. if quality == "" {
  97. quality = "0"
  98. }
  99. args = append(args, "-x", "--audio-format", "mp3", "--audio-quality", quality)
  100. }
  101. if dir := strings.TrimSpace(s.GetConfig().Dir); dir != "" {
  102. args = append(args, "-P", dir) // 下载保存目录(设置中配置)
  103. }
  104. args = append(args, "-o", "%(title)s.%(ext)s", url)
  105. cmd := exec.CommandContext(ctx, path, args...)
  106. hideProcessWindow(cmd)
  107. if err := s.streamProcess(cmd); err != nil {
  108. s.cancel = nil
  109. cancel()
  110. s.mu.Unlock()
  111. return fmt.Errorf("启动下载失败:%w", err)
  112. }
  113. s.mu.Unlock()
  114. return nil
  115. }
  116. // StopDownload 终止下载任务(若在运行)。
  117. func (s *YTDLPService) StopDownload() {
  118. s.mu.Lock()
  119. cancel := s.cancel
  120. s.mu.Unlock()
  121. if cancel != nil {
  122. cancel()
  123. }
  124. }
  125. // FetchTitle 用 yt-dlp 解析视频标题(不下载),供工具列表显示。
  126. // 解析失败(网络/链接失效等)返回空字符串,由前端回退显示链接。
  127. func (s *YTDLPService) FetchTitle(url string) string {
  128. url = strings.TrimSpace(url)
  129. if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
  130. return ""
  131. }
  132. path, err := exec.LookPath("yt-dlp")
  133. if err != nil {
  134. return ""
  135. }
  136. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  137. defer cancel()
  138. cmd := exec.CommandContext(ctx, path, "--encoding", "utf-8", "--no-warnings", "--skip-download", "--get-title", url)
  139. hideProcessWindow(cmd)
  140. out, err := cmd.Output()
  141. if err != nil {
  142. return ""
  143. }
  144. title := strings.TrimSpace(string(out))
  145. if i := strings.IndexAny(title, "\r\n"); i >= 0 {
  146. title = strings.TrimSpace(title[:i]) // 只取第一行
  147. }
  148. return title
  149. }
  150. // streamProcess 启动进程并把输出逐行推送给前端。调用方需持有 s.mu。
  151. func (s *YTDLPService) streamProcess(cmd *exec.Cmd) error {
  152. stdout, err := cmd.StdoutPipe()
  153. if err != nil {
  154. return err
  155. }
  156. stderr, err := cmd.StderrPipe()
  157. if err != nil {
  158. return err
  159. }
  160. if err := cmd.Start(); err != nil {
  161. return err
  162. }
  163. emit := func(raw string) {
  164. line := strings.TrimSpace(crocAnsiRe.ReplaceAllString(raw, ""))
  165. if line == "" {
  166. return
  167. }
  168. app.Event.Emit("ytdlp:log", map[string]string{"line": line})
  169. }
  170. go scanCrocStream(stdout, emit)
  171. go scanCrocStream(stderr, emit)
  172. go func() {
  173. waitErr := cmd.Wait()
  174. exitCode := 0
  175. if waitErr != nil {
  176. var ee *exec.ExitError
  177. if errors.As(waitErr, &ee) {
  178. exitCode = ee.ExitCode()
  179. } else {
  180. exitCode = -1
  181. }
  182. }
  183. s.mu.Lock()
  184. s.cancel = nil
  185. s.mu.Unlock()
  186. app.Event.Emit("ytdlp:exit", map[string]any{"code": exitCode})
  187. }()
  188. return nil
  189. }