mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-10 23:36:37 +08:00
feat: 站点适配器 FlareSolverr 集成 + 并发搜索优化
- site_adapter.go: SiteConfig 扩展 UserAgent/Timeout/Extra/FlareSolverrURL 字段 - site_adapter.go: doRequest() GET 请求支持 FlareSolverr 代理绕过 Cloudflare/WAF - site_adapter.go: buildRequest() 使用 cfg.UserAgent(覆盖默认 UA) - site_adapter.go: MTeam api_key 认证改为 Authorization: Bearer 格式 - site_adapter.go: 修复 2 个 go vet 冗余 StatusFound 警告 - site.go: Search() 重构为 sync.WaitGroup 并发执行,提升多站搜索性能 - site.go: siteModelToConfig() 改为 SiteService 方法,按 BrowserEmulation 填充 FlareSolverrURL - site.go: Search() 空关键词返回 [] 而非 error,nil slice 防护 - 新增 system_handler.go: 系统工具探测接口 - 新增 ffmpeg_auto_install.go: FFmpeg 自动安装服务 - 新增 scripts/install-ffmpeg.ps1: Windows FFmpeg 安装脚本
This commit is contained in:
@@ -0,0 +1,144 @@
|
|||||||
|
// Package handler — system tools detection.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SystemHandler handles system-related endpoints.
|
||||||
|
type SystemHandler struct {
|
||||||
|
cfg *config.Config
|
||||||
|
log *zap.Logger
|
||||||
|
svc *service.Container
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSystemHandler is the constructor.
|
||||||
|
func NewSystemHandler(cfg *config.Config, log *zap.Logger, svc *service.Container) *SystemHandler {
|
||||||
|
return &SystemHandler{cfg: cfg, log: log, svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolStatus represents the detection status of a system tool.
|
||||||
|
type ToolStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
ConfigKey string `json:"config_key"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Detected bool `json:"detected"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetToolsStatus returns the status of system tools.
|
||||||
|
func (h *SystemHandler) GetToolsStatus(c *gin.Context) {
|
||||||
|
tools := []ToolStatus{
|
||||||
|
{Name: "ffprobe", DisplayName: "FFprobe", ConfigKey: "app.ffprobe_path"},
|
||||||
|
{Name: "ffmpeg", DisplayName: "FFmpeg", ConfigKey: "app.ffmpeg_path"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range tools {
|
||||||
|
// Check configured path first
|
||||||
|
var configuredPath string
|
||||||
|
switch tools[i].ConfigKey {
|
||||||
|
case "app.ffprobe_path":
|
||||||
|
configuredPath = h.cfg.App.FFprobePath
|
||||||
|
if configuredPath == "" {
|
||||||
|
configuredPath = "ffprobe"
|
||||||
|
}
|
||||||
|
case "app.ffmpeg_path":
|
||||||
|
configuredPath = h.cfg.App.FFmpegPath
|
||||||
|
if configuredPath == "" {
|
||||||
|
configuredPath = "ffmpeg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find the tool
|
||||||
|
path, err := exec.LookPath(configuredPath)
|
||||||
|
if err == nil {
|
||||||
|
tools[i].Detected = true
|
||||||
|
tools[i].Path = path
|
||||||
|
// Try to get version
|
||||||
|
tools[i].Version = getToolVersion(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"tools": tools,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getToolVersion attempts to get the version of a tool.
|
||||||
|
func getToolVersion(path string) string {
|
||||||
|
out, err := exec.Command(path, "-version").Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract first line as version info
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return strings.TrimSpace(lines[0])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallTools attempts to auto-install system tools (ffmpeg/ffprobe)
|
||||||
|
func (h *SystemHandler) InstallTools(c *gin.Context) {
|
||||||
|
h.log.Info("Received tools auto-install request")
|
||||||
|
|
||||||
|
// Call service layer to auto-install
|
||||||
|
ffprobePath, ffmpegPath := service.AutoInstallFFmpeg(h.log, h.cfg)
|
||||||
|
|
||||||
|
result := gin.H{
|
||||||
|
"installed": ffprobePath != "" || ffmpegPath != "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if ffprobePath != "" {
|
||||||
|
result["ffprobe_path"] = ffprobePath
|
||||||
|
result["ffprobe_installed"] = true
|
||||||
|
}
|
||||||
|
if ffmpegPath != "" {
|
||||||
|
result["ffmpeg_path"] = ffmpegPath
|
||||||
|
result["ffmpeg_installed"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-detect tool status
|
||||||
|
tools := []ToolStatus{
|
||||||
|
{Name: "ffprobe", DisplayName: "FFprobe", ConfigKey: "app.ffprobe_path"},
|
||||||
|
{Name: "ffmpeg", DisplayName: "FFmpeg", ConfigKey: "app.ffmpeg_path"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range tools {
|
||||||
|
var configuredPath string
|
||||||
|
switch tools[i].ConfigKey {
|
||||||
|
case "app.ffprobe_path":
|
||||||
|
configuredPath = h.cfg.App.FFprobePath
|
||||||
|
if configuredPath == "" {
|
||||||
|
configuredPath = "ffprobe"
|
||||||
|
}
|
||||||
|
case "app.ffmpeg_path":
|
||||||
|
configuredPath = h.cfg.App.FFmpegPath
|
||||||
|
if configuredPath == "" {
|
||||||
|
configuredPath = "ffmpeg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := exec.LookPath(configuredPath)
|
||||||
|
if err == nil {
|
||||||
|
tools[i].Detected = true
|
||||||
|
tools[i].Path = path
|
||||||
|
tools[i].Version = getToolVersion(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result["tools"] = tools
|
||||||
|
|
||||||
|
h.log.Info("Tool installation completed", zap.Any("result", result))
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoInstallFFmpeg 在启动时检测并自动安装 ffmpeg/ffprobe
|
||||||
|
func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpegPath string) {
|
||||||
|
// 1. 检查配置中是否已指定路径
|
||||||
|
if cfg.App.FFprobePath != "" {
|
||||||
|
if _, err := os.Stat(cfg.App.FFprobePath); err == nil {
|
||||||
|
log.Info("使用配置的 ffprobe", zap.String("path", cfg.App.FFprobePath))
|
||||||
|
return cfg.App.FFprobePath, cfg.App.FFmpegPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查系统 PATH
|
||||||
|
if path, err := exec.LookPath("ffprobe"); err == nil {
|
||||||
|
log.Info("在 PATH 中找到 ffprobe", zap.String("path", path))
|
||||||
|
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||||
|
return path, ffmpegPath
|
||||||
|
}
|
||||||
|
return path, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查默认安装位置
|
||||||
|
defaultDir := getDefaultInstallDir()
|
||||||
|
ffprobeDefault := filepath.Join(defaultDir, "bin", "ffprobe.exe")
|
||||||
|
ffmpegDefault := filepath.Join(defaultDir, "bin", "ffmpeg.exe")
|
||||||
|
|
||||||
|
if _, err := os.Stat(ffprobeDefault); err == nil {
|
||||||
|
log.Info("在默认位置找到 ffprobe", zap.String("path", ffprobeDefault))
|
||||||
|
return ffprobeDefault, ffmpegDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 尝试自动安装
|
||||||
|
log.Warn("未找到 ffmpeg/ffprobe,尝试自动安装...")
|
||||||
|
installed, err := tryAutoInstall(log, defaultDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("自动安装失败,请手动安装 ffmpeg", zap.Error(err))
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if installed {
|
||||||
|
if _, err := os.Stat(ffprobeDefault); err == nil {
|
||||||
|
log.Info("自动安装成功", zap.String("path", ffprobeDefault))
|
||||||
|
// 更新配置
|
||||||
|
updateConfigPaths(cfg, ffprobeDefault, ffmpegDefault)
|
||||||
|
return ffprobeDefault, ffmpegDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDefaultInstallDir 返回默认安装目录
|
||||||
|
func getDefaultInstallDir() string {
|
||||||
|
if wd, err := os.Getwd(); err == nil {
|
||||||
|
if _, err := os.Stat(filepath.Join(wd, "scripts", "install-ffmpeg.ps1")); err == nil {
|
||||||
|
return filepath.Join(wd, "tools", "ffmpeg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "./tools/ffmpeg"
|
||||||
|
}
|
||||||
|
exeDir := filepath.Dir(exePath)
|
||||||
|
return filepath.Join(exeDir, "tools", "ffmpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryAutoInstall 尝试自动下载并安装 ffmpeg
|
||||||
|
func tryAutoInstall(log *zap.Logger, installDir string) (bool, error) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return downloadFFmpegWindows(log, installDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadFFmpegWindows 下载 Windows 版本的 ffmpeg
|
||||||
|
func downloadFFmpegWindows(log *zap.Logger, installDir string) (bool, error) {
|
||||||
|
log.Info("开始下载 ffmpeg...")
|
||||||
|
|
||||||
|
// 创建安装目录
|
||||||
|
if err := os.MkdirAll(installDir, 0755); err != nil {
|
||||||
|
return false, fmt.Errorf("创建安装目录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir, err := os.MkdirTemp("", "mediastationgo-ffmpeg-*")
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("创建临时目录失败: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// 下载 URL (使用 gyani.org 的静态构建)
|
||||||
|
arch := "win64"
|
||||||
|
if !is64Bit() {
|
||||||
|
arch = "win32"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先尝试从 gyan.dev 下载(更可靠)
|
||||||
|
downloadURL := fmt.Sprintf("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip")
|
||||||
|
|
||||||
|
log.Info("下载 ffmpeg", zap.String("url", downloadURL))
|
||||||
|
|
||||||
|
// 使用 Go 下载
|
||||||
|
zipPath := filepath.Join(tempDir, "ffmpeg.zip")
|
||||||
|
if err := downloadFile(log, downloadURL, zipPath); err != nil {
|
||||||
|
// 尝试备用 URL
|
||||||
|
backupURL := fmt.Sprintf("https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-%s-gpl.zip", arch)
|
||||||
|
log.Info("尝试备用下载地址", zap.String("url", backupURL))
|
||||||
|
if err2 := downloadFile(log, backupURL, zipPath); err2 != nil {
|
||||||
|
return false, fmt.Errorf("下载失败: %v, %v", err, err2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解压
|
||||||
|
log.Info("解压 ffmpeg...")
|
||||||
|
extractDir := filepath.Join(tempDir, "extract")
|
||||||
|
if err := unzip(log, zipPath, extractDir); err != nil {
|
||||||
|
return false, fmt.Errorf("解压失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
packageRoot, err := findFFmpegPackageRoot(extractDir)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := copyDirContents(packageRoot, installDir); err != nil {
|
||||||
|
return false, fmt.Errorf("复制 ffmpeg 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpegBin := filepath.Join(installDir, "bin", "ffmpeg.exe")
|
||||||
|
ffprobeBin := filepath.Join(installDir, "bin", "ffprobe.exe")
|
||||||
|
if _, err := os.Stat(ffmpegBin); err != nil {
|
||||||
|
return false, fmt.Errorf("安装后未找到 ffmpeg: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(ffprobeBin); err != nil {
|
||||||
|
return false, fmt.Errorf("安装后未找到 ffprobe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("ffmpeg 安装完成", zap.String("dir", installDir))
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadFile 下载文件
|
||||||
|
func downloadFile(log *zap.Logger, url, filepath string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("下载失败,HTTP 状态码: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// unzip 解压 ZIP 文件 (简化版,实际应该使用 archive/zip)
|
||||||
|
func unzip(log *zap.Logger, zipPath, destDir string) error {
|
||||||
|
// Windows 使用 PowerShell 解压
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := exec.Command("powershell", "-Command", fmt.Sprintf("Expand-Archive -Path '%s' -DestinationPath '%s' -Force", zipPath, destDir))
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("不支持的操作系统")
|
||||||
|
}
|
||||||
|
|
||||||
|
func findFFmpegPackageRoot(root string) (string, error) {
|
||||||
|
var ffmpegPath string
|
||||||
|
var ffprobePath string
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(d.Name()) {
|
||||||
|
case "ffmpeg.exe":
|
||||||
|
ffmpegPath = path
|
||||||
|
case "ffprobe.exe":
|
||||||
|
ffprobePath = path
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("扫描解压目录失败: %w", err)
|
||||||
|
}
|
||||||
|
if ffmpegPath == "" || ffprobePath == "" {
|
||||||
|
return "", fmt.Errorf("解压后未找到 ffmpeg/ffprobe 可执行文件")
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Dir(filepath.Dir(ffmpegPath)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyDirContents(srcDir, dstDir string) error {
|
||||||
|
entries, err := os.ReadDir(srcDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
srcPath := filepath.Join(srcDir, entry.Name())
|
||||||
|
dstPath := filepath.Join(dstDir, entry.Name())
|
||||||
|
if err := copyTree(srcPath, dstPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyTree(srcPath, dstPath string) error {
|
||||||
|
info, err := os.Stat(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
if err := os.MkdirAll(dstPath, info.Mode()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if err := copyTree(filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateConfigPaths 更新配置文件中的路径
|
||||||
|
func updateConfigPaths(cfg *config.Config, ffprobePath, ffmpegPath string) {
|
||||||
|
cfg.App.FFprobePath = ffprobePath
|
||||||
|
cfg.App.FFmpegPath = ffmpegPath
|
||||||
|
|
||||||
|
// 保存到配置文件
|
||||||
|
// 这里需要调用 config 包的保存函数
|
||||||
|
log := zap.L().Named("config")
|
||||||
|
log.Info("已更新 ffmpeg 路径配置",
|
||||||
|
zap.String("ffprobe", ffprobePath),
|
||||||
|
zap.String("ffmpeg", ffmpegPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// is64Bit 检查是否为 64 位系统
|
||||||
|
func is64Bit() bool {
|
||||||
|
return true // 简化处理,假设为 64 位
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckFFmpegStatus 检查 ffmpeg/ffprobe 状态 (供 API 使用)
|
||||||
|
func CheckFFmpegStatus(ffprobePath, ffmpegPath string) map[string]interface{} {
|
||||||
|
status := map[string]interface{}{
|
||||||
|
"ffprobe_installed": false,
|
||||||
|
"ffmpeg_installed": false,
|
||||||
|
"auto_installable": runtime.GOOS == "windows",
|
||||||
|
}
|
||||||
|
|
||||||
|
if ffprobePath != "" {
|
||||||
|
if _, err := os.Stat(ffprobePath); err == nil {
|
||||||
|
status["ffprobe_installed"] = true
|
||||||
|
status["ffprobe_path"] = ffprobePath
|
||||||
|
|
||||||
|
// 获取版本
|
||||||
|
cmd := exec.Command(ffprobePath, "-version")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
// 提取版本信息(第一行)
|
||||||
|
lines := bytes.Split(out, []byte("\n"))
|
||||||
|
if len(lines) > 0 {
|
||||||
|
status["ffprobe_version"] = string(bytes.TrimSpace(lines[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ffmpegPath != "" {
|
||||||
|
if _, err := os.Stat(ffmpegPath); err == nil {
|
||||||
|
status["ffmpeg_installed"] = true
|
||||||
|
status["ffmpeg_path"] = ffmpegPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
+105
-42
@@ -7,8 +7,11 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -46,10 +49,13 @@ func (s *SiteService) Create(ctx context.Context, site *model.Site) error {
|
|||||||
return s.repo.DB.WithContext(ctx).Create(site).Error
|
return s.repo.DB.WithContext(ctx).Create(site).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns every site ordered by priority (lower = higher priority).
|
// List returns every site ordered by created_at.
|
||||||
func (s *SiteService) List(ctx context.Context) ([]model.Site, error) {
|
func (s *SiteService) List(ctx context.Context) ([]model.Site, error) {
|
||||||
var sites []model.Site
|
var sites []model.Site
|
||||||
err := s.repo.DB.WithContext(ctx).Order("created_at asc").Find(&sites).Error
|
err := s.repo.DB.WithContext(ctx).Order("created_at asc").Find(&sites).Error
|
||||||
|
if sites == nil {
|
||||||
|
sites = []model.Site{}
|
||||||
|
}
|
||||||
return sites, err
|
return sites, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,66 +131,123 @@ type SearchResult struct {
|
|||||||
|
|
||||||
// Search fans out a keyword query to every enabled site and returns
|
// Search fans out a keyword query to every enabled site and returns
|
||||||
// merged results sorted by seeders descending.
|
// merged results sorted by seeders descending.
|
||||||
|
// Uses concurrent search with sync.WaitGroup for performance.
|
||||||
func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResult, error) {
|
func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResult, error) {
|
||||||
if strings.TrimSpace(keyword) == "" {
|
if strings.TrimSpace(keyword) == "" {
|
||||||
return nil, errors.New("keyword required")
|
return []SearchResult{}, nil
|
||||||
}
|
}
|
||||||
sites, err := s.List(ctx)
|
sites, err := s.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []SearchResult
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
wg sync.WaitGroup
|
||||||
|
results []SearchResult
|
||||||
|
)
|
||||||
|
|
||||||
for i := range sites {
|
for i := range sites {
|
||||||
if !sites[i].Enabled {
|
if !sites[i].Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
adapter := NewSiteAdapter(&sites[i])
|
wg.Add(1)
|
||||||
if adapter == nil {
|
go func(site model.Site) {
|
||||||
continue
|
defer wg.Done()
|
||||||
}
|
|
||||||
cfg := siteModelToConfig(&sites[i])
|
adapter := NewSiteAdapter(&site)
|
||||||
result, err := adapter.Search(ctx, cfg, keyword, 1)
|
if adapter == nil {
|
||||||
if err != nil {
|
return
|
||||||
s.log.Debug("site search failed",
|
}
|
||||||
zap.String("site", sites[i].Name), zap.Error(err))
|
|
||||||
continue
|
cfg := s.siteModelToConfig(&site)
|
||||||
}
|
|
||||||
for _, item := range result.Items {
|
// Use site timeout or default 30s
|
||||||
results = append(results, SearchResult{
|
timeout := time.Duration(site.Timeout) * time.Second
|
||||||
SiteName: sites[i].Name,
|
if timeout <= 0 {
|
||||||
SiteID: sites[i].ID,
|
timeout = 30 * time.Second
|
||||||
Title: item.Title,
|
}
|
||||||
TorrentURL: item.DetailURL,
|
ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout)
|
||||||
DownloadURL: item.DownloadURL,
|
defer cancel()
|
||||||
Size: item.Size,
|
|
||||||
Seeders: item.Seeders,
|
result, err := adapter.Search(ctxWithTimeout, cfg, keyword, 1)
|
||||||
Leechers: item.Leechers,
|
if err != nil {
|
||||||
Free: item.Free,
|
s.log.Debug("site search failed",
|
||||||
})
|
zap.String("site", site.Name), zap.Error(err))
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := result.Items
|
||||||
|
if items == nil {
|
||||||
|
items = []TorrentItem{}
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
mu.Lock()
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
SiteName: site.Name,
|
||||||
|
SiteID: site.ID,
|
||||||
|
Title: item.Title,
|
||||||
|
TorrentURL: item.DetailURL,
|
||||||
|
DownloadURL: item.DownloadURL,
|
||||||
|
Size: item.Size,
|
||||||
|
Seeders: item.Seeders,
|
||||||
|
Leechers: item.Leechers,
|
||||||
|
Free: item.Free,
|
||||||
|
})
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}(sites[i])
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Ensure results is never nil (return [] instead of null in JSON)
|
||||||
|
if results == nil {
|
||||||
|
results = []SearchResult{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by seeders desc.
|
// Sort by seeders desc.
|
||||||
for i := 0; i < len(results); i++ {
|
sort.Slice(results, func(i, j int) bool {
|
||||||
for j := i + 1; j < len(results); j++ {
|
return results[i].Seeders > results[j].Seeders
|
||||||
if results[j].Seeders > results[i].Seeders {
|
})
|
||||||
results[i], results[j] = results[j], results[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// siteModelToConfig 将 model.Site 转换为适配器使用的 SiteConfig。
|
// siteModelToConfig 将 model.Site 转换为适配器使用的 SiteConfig。
|
||||||
func siteModelToConfig(s *model.Site) SiteConfig {
|
// 当全局 FlareSolverr 已启用且此站点开启了 BrowserEmulation 时,填充 FlareSolverrURL。
|
||||||
|
func (svc *SiteService) siteModelToConfig(s *model.Site) SiteConfig {
|
||||||
|
timeout := time.Duration(s.Timeout) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
userAgent := s.UserAgent
|
||||||
|
if userAgent == "" {
|
||||||
|
userAgent = model.DefaultUserAgent
|
||||||
|
}
|
||||||
|
var extra map[string]string
|
||||||
|
if s.Extra != "" {
|
||||||
|
_ = json.Unmarshal([]byte(s.Extra), &extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-site FlareSolverr opt-in: only when global FlareSolverr is enabled
|
||||||
|
// AND this site has BrowserEmulation turned on.
|
||||||
|
flareSolverrURL := ""
|
||||||
|
if svc.flareSolverrURL != "" && s.BrowserEmulation {
|
||||||
|
flareSolverrURL = svc.flareSolverrURL
|
||||||
|
}
|
||||||
|
|
||||||
return SiteConfig{
|
return SiteConfig{
|
||||||
Name: s.Name,
|
Name: s.Name,
|
||||||
Type: s.Type,
|
Type: s.Type,
|
||||||
URL: s.URL,
|
URL: s.URL,
|
||||||
AuthType: s.AuthType,
|
AuthType: s.AuthType,
|
||||||
Cookie: s.Cookie,
|
Cookie: s.Cookie,
|
||||||
APIKey: s.APIKey,
|
APIKey: s.APIKey,
|
||||||
AuthHeader: s.AuthHeader,
|
AuthHeader: s.AuthHeader,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
Timeout: timeout,
|
||||||
|
Extra: extra,
|
||||||
|
FlareSolverrURL: flareSolverrURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,19 +13,23 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/helper"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SiteConfig 站点配置(从 model.Site 解密后的纯文本)。
|
// SiteConfig 站点配置(从 model.Site 解密后的纯文本)。
|
||||||
type SiteConfig struct {
|
type SiteConfig struct {
|
||||||
Name string
|
Name string
|
||||||
Type string
|
Type string
|
||||||
URL string
|
URL string
|
||||||
AuthType string
|
AuthType string
|
||||||
Cookie string
|
Cookie string
|
||||||
APIKey string
|
APIKey string
|
||||||
AuthHeader string
|
AuthHeader string
|
||||||
Extra map[string]string // JSON 扩展配置
|
UserAgent string // 自定义 User-Agent
|
||||||
|
Timeout time.Duration // 请求超时
|
||||||
|
Extra map[string]string // JSON 扩展配置
|
||||||
|
FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
|
// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
|
||||||
@@ -110,7 +114,12 @@ func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, bo
|
|||||||
req.Header.Set("Cookie", cfg.Cookie)
|
req.Header.Set("Cookie", cfg.Cookie)
|
||||||
}
|
}
|
||||||
case "api_key":
|
case "api_key":
|
||||||
if cfg.APIKey != "" {
|
// MTeam 使用 Authorization: Bearer 格式
|
||||||
|
if cfg.Type == "mteam" {
|
||||||
|
if cfg.APIKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||||
|
}
|
||||||
|
} else if cfg.APIKey != "" {
|
||||||
req.Header.Set("X-API-Key", cfg.APIKey)
|
req.Header.Set("X-API-Key", cfg.APIKey)
|
||||||
}
|
}
|
||||||
case "auth_header":
|
case "auth_header":
|
||||||
@@ -124,12 +133,34 @@ func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
// 使用 SiteConfig 中的 UserAgent(如果提供),否则使用默认值
|
||||||
|
userAgent := cfg.UserAgent
|
||||||
|
if userAgent == "" {
|
||||||
|
userAgent = model.DefaultUserAgent
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// doRequest 执行 HTTP 请求并返回响应体。
|
// doRequest 执行 HTTP 请求并返回响应体。
|
||||||
|
// 当 cfg.FlareSolverrURL 已配置且方法为 GET 时,通过 FlareSolverr 代理请求
|
||||||
|
// 以绕过 Cloudflare/WAF 挑战验证。
|
||||||
func doRequest(ctx context.Context, client *http.Client, method, rawURL string, cfg SiteConfig, body io.Reader) ([]byte, int, error) {
|
func doRequest(ctx context.Context, client *http.Client, method, rawURL string, cfg SiteConfig, body io.Reader) ([]byte, int, error) {
|
||||||
|
// ── FlareSolverr 浏览器模拟路径(仅 GET) ──────────────────────────
|
||||||
|
if cfg.FlareSolverrURL != "" && method == "GET" {
|
||||||
|
timeout := int(cfg.Timeout.Seconds())
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 30
|
||||||
|
}
|
||||||
|
pageBody, err := helper.FetchURLWithFlareSolverr(
|
||||||
|
cfg.FlareSolverrURL, rawURL, cfg.Cookie, timeout, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("flareSolverr: %w", err)
|
||||||
|
}
|
||||||
|
return []byte(pageBody), http.StatusOK, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 直接 HTTP 请求路径 ─────────────────────────────────────────────
|
||||||
req, err := buildRequest(ctx, method, rawURL, cfg, body)
|
req, err := buildRequest(ctx, method, rawURL, cfg, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@@ -176,7 +207,7 @@ func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) erro
|
|||||||
}
|
}
|
||||||
defer httpResp.Body.Close()
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
if httpResp.StatusCode == http.StatusFound || httpResp.StatusCode == http.StatusFound {
|
if httpResp.StatusCode == http.StatusFound {
|
||||||
return fmt.Errorf("authentication failed: redirected to login page")
|
return fmt.Errorf("authentication failed: redirected to login page")
|
||||||
}
|
}
|
||||||
if httpResp.StatusCode != http.StatusOK {
|
if httpResp.StatusCode != http.StatusOK {
|
||||||
@@ -1022,7 +1053,7 @@ func (a *DiscuzAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("authenticate: %w", err)
|
return fmt.Errorf("authenticate: %w", err)
|
||||||
}
|
}
|
||||||
if status == http.StatusFound || status == http.StatusFound {
|
if status == http.StatusFound {
|
||||||
return fmt.Errorf("authentication failed: redirected to login")
|
return fmt.Errorf("authentication failed: redirected to login")
|
||||||
}
|
}
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Windows 自动安装 ffmpeg/ffprobe 脚本
|
||||||
|
# 在项目首次运行或用户手动点击工具页按钮时执行。
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$InstallDir = (Join-Path $PSScriptRoot "..\tools\ffmpeg"),
|
||||||
|
[string]$ConfigFile = (Join-Path $PSScriptRoot "..\config.yaml")
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$previousProgressPreference = $ProgressPreference
|
||||||
|
$ProgressPreference = "SilentlyContinue"
|
||||||
|
$tempRoot = $null
|
||||||
|
|
||||||
|
function Test-ToolInstalled {
|
||||||
|
param([string]$BaseDir)
|
||||||
|
|
||||||
|
$ffmpeg = Join-Path $BaseDir "bin\ffmpeg.exe"
|
||||||
|
$ffprobe = Join-Path $BaseDir "bin\ffprobe.exe"
|
||||||
|
return (Test-Path $ffmpeg) -and (Test-Path $ffprobe)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Update-ConfigFile {
|
||||||
|
param(
|
||||||
|
[string]$Path,
|
||||||
|
[string]$FFmpegPath,
|
||||||
|
[string]$FFprobePath
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!(Test-Path $Path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = Get-Content -Path $Path
|
||||||
|
$content = $content | ForEach-Object {
|
||||||
|
if ($_ -match '^\s*ffmpeg_path:') {
|
||||||
|
" ffmpeg_path: $FFmpegPath"
|
||||||
|
} elseif ($_ -match '^\s*ffprobe_path:') {
|
||||||
|
" ffprobe_path: $FFprobePath"
|
||||||
|
} else {
|
||||||
|
$_
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Content -Path $Path -Value $content -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
function Cleanup-Temp {
|
||||||
|
if ($tempRoot -and (Test-Path $tempRoot)) {
|
||||||
|
Remove-Item -LiteralPath $tempRoot -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== MediaStationGo ffmpeg 自动安装脚本 ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$InstallDir = [System.IO.Path]::GetFullPath($InstallDir)
|
||||||
|
$ConfigFile = [System.IO.Path]::GetFullPath($ConfigFile)
|
||||||
|
$ffmpegPath = Join-Path $InstallDir "bin\ffmpeg.exe"
|
||||||
|
$ffprobePath = Join-Path $InstallDir "bin\ffprobe.exe"
|
||||||
|
$arch = if ([Environment]::Is64BitOperatingSystem) { "win64" } else { "win32" }
|
||||||
|
|
||||||
|
Write-Host "检测到系统架构: $arch" -ForegroundColor Yellow
|
||||||
|
Write-Host "安装目录: $InstallDir" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
trap {
|
||||||
|
Write-Host "✗ 自动安装失败: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
if ($_.InvocationInfo) {
|
||||||
|
Write-Host ("行 {0}: {1}" -f $_.InvocationInfo.ScriptLineNumber, $_.InvocationInfo.Line.Trim()) -ForegroundColor DarkRed
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "请手动下载安装并放到以下目录:" -ForegroundColor Yellow
|
||||||
|
Write-Host $InstallDir -ForegroundColor Yellow
|
||||||
|
$ProgressPreference = $previousProgressPreference
|
||||||
|
Cleanup-Temp
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-ToolInstalled -BaseDir $InstallDir) {
|
||||||
|
Write-Host "✓ ffmpeg 已安装" -ForegroundColor Green
|
||||||
|
& $ffprobePath -version | Select-Object -First 1
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$downloadUrls = @(
|
||||||
|
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip",
|
||||||
|
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-$arch-gpl.zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
|
|
||||||
|
$tempRoot = Join-Path $env:TEMP ("mediastationgo-ffmpeg-" + [guid]::NewGuid().ToString("N"))
|
||||||
|
$zipFile = Join-Path $tempRoot "ffmpeg.zip"
|
||||||
|
$extractDir = Join-Path $tempRoot "extract"
|
||||||
|
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
|
||||||
|
|
||||||
|
$downloaded = $false
|
||||||
|
foreach ($downloadUrl in $downloadUrls) {
|
||||||
|
try {
|
||||||
|
Write-Host "下载 ffmpeg..." -ForegroundColor Yellow
|
||||||
|
Write-Host "URL: $downloadUrl"
|
||||||
|
Invoke-WebRequest -Uri $downloadUrl -OutFile $zipFile
|
||||||
|
$downloaded = $true
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
Write-Host "下载失败,尝试备用地址: $downloadUrl" -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $downloaded) {
|
||||||
|
throw "所有下载地址均不可用"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "解压文件..." -ForegroundColor Yellow
|
||||||
|
Expand-Archive -Path $zipFile -DestinationPath $extractDir -Force
|
||||||
|
|
||||||
|
$ffmpegBinaryPath = "" + (Get-ChildItem -Path $extractDir -Recurse -Filter "ffmpeg.exe" -File |
|
||||||
|
Select-Object -First 1 -ExpandProperty FullName)
|
||||||
|
$ffprobeBinaryPath = "" + (Get-ChildItem -Path $extractDir -Recurse -Filter "ffprobe.exe" -File |
|
||||||
|
Select-Object -First 1 -ExpandProperty FullName)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ffmpegBinaryPath) -or [string]::IsNullOrWhiteSpace($ffprobeBinaryPath)) {
|
||||||
|
Write-Host "未找到预期二进制,解压结果样本:" -ForegroundColor DarkYellow
|
||||||
|
Get-ChildItem -Path $extractDir -Recurse -File | Select-Object -First 20 FullName | ForEach-Object {
|
||||||
|
Write-Host $_.FullName -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
throw "解压后未找到包含 bin\ffmpeg.exe 的目录"
|
||||||
|
}
|
||||||
|
|
||||||
|
$packageRoot = Split-Path -Parent (Split-Path -Parent $ffmpegBinaryPath)
|
||||||
|
Copy-Item -Path (Join-Path $packageRoot "*") -Destination $InstallDir -Recurse -Force
|
||||||
|
|
||||||
|
if (-not (Test-ToolInstalled -BaseDir $InstallDir)) {
|
||||||
|
throw "ffmpeg 文件未正确安装到 $InstallDir\bin"
|
||||||
|
}
|
||||||
|
|
||||||
|
$env:PATH = "$InstallDir\bin;$env:PATH"
|
||||||
|
Update-ConfigFile -Path $ConfigFile -FFmpegPath $ffmpegPath -FFprobePath $ffprobePath
|
||||||
|
|
||||||
|
Write-Host "✓ 安装完成" -ForegroundColor Green
|
||||||
|
Write-Host "ffmpeg: $ffmpegPath" -ForegroundColor Green
|
||||||
|
Write-Host "ffprobe: $ffprobePath" -ForegroundColor Green
|
||||||
|
& $ffprobePath -version | Select-Object -First 1
|
||||||
|
$ProgressPreference = $previousProgressPreference
|
||||||
|
Cleanup-Temp
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "提示: 如需永久加入 PATH,请以管理员执行:" -ForegroundColor Cyan
|
||||||
|
Write-Host "[Environment]::SetEnvironmentVariable('PATH', `$env:PATH + ';$InstallDir\bin', 'Machine')" -ForegroundColor Gray
|
||||||
Reference in New Issue
Block a user