diff --git a/internal/handler/system_handler.go b/internal/handler/system_handler.go new file mode 100644 index 0000000..527d882 --- /dev/null +++ b/internal/handler/system_handler.go @@ -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) +} diff --git a/internal/service/ffmpeg_auto_install.go b/internal/service/ffmpeg_auto_install.go new file mode 100644 index 0000000..94348aa --- /dev/null +++ b/internal/service/ffmpeg_auto_install.go @@ -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 +} diff --git a/internal/service/site.go b/internal/service/site.go index ecbb3ae..f693040 100644 --- a/internal/service/site.go +++ b/internal/service/site.go @@ -7,8 +7,11 @@ package service import ( "context" + "encoding/json" "errors" + "sort" "strings" + "sync" "time" "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 } -// 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) { var sites []model.Site err := s.repo.DB.WithContext(ctx).Order("created_at asc").Find(&sites).Error + if sites == nil { + sites = []model.Site{} + } return sites, err } @@ -125,66 +131,123 @@ type SearchResult struct { // Search fans out a keyword query to every enabled site and returns // 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) { if strings.TrimSpace(keyword) == "" { - return nil, errors.New("keyword required") + return []SearchResult{}, nil } sites, err := s.List(ctx) if err != nil { return nil, err } - var results []SearchResult + var ( + mu sync.Mutex + wg sync.WaitGroup + results []SearchResult + ) + for i := range sites { if !sites[i].Enabled { continue } - adapter := NewSiteAdapter(&sites[i]) - if adapter == nil { - continue - } - cfg := siteModelToConfig(&sites[i]) - result, err := adapter.Search(ctx, cfg, keyword, 1) - if err != nil { - s.log.Debug("site search failed", - zap.String("site", sites[i].Name), zap.Error(err)) - continue - } - for _, item := range result.Items { - results = append(results, SearchResult{ - SiteName: sites[i].Name, - SiteID: sites[i].ID, - Title: item.Title, - TorrentURL: item.DetailURL, - DownloadURL: item.DownloadURL, - Size: item.Size, - Seeders: item.Seeders, - Leechers: item.Leechers, - Free: item.Free, - }) - } + wg.Add(1) + go func(site model.Site) { + defer wg.Done() + + adapter := NewSiteAdapter(&site) + if adapter == nil { + return + } + + cfg := s.siteModelToConfig(&site) + + // Use site timeout or default 30s + timeout := time.Duration(site.Timeout) * time.Second + if timeout <= 0 { + timeout = 30 * time.Second + } + ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + result, err := adapter.Search(ctxWithTimeout, cfg, keyword, 1) + if err != nil { + 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. - for i := 0; i < len(results); i++ { - for j := i + 1; j < len(results); j++ { - if results[j].Seeders > results[i].Seeders { - results[i], results[j] = results[j], results[i] - } - } - } + sort.Slice(results, func(i, j int) bool { + return results[i].Seeders > results[j].Seeders + }) return results, nil } // 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{ - Name: s.Name, - Type: s.Type, - URL: s.URL, - AuthType: s.AuthType, - Cookie: s.Cookie, - APIKey: s.APIKey, - AuthHeader: s.AuthHeader, + Name: s.Name, + Type: s.Type, + URL: s.URL, + AuthType: s.AuthType, + Cookie: s.Cookie, + APIKey: s.APIKey, + AuthHeader: s.AuthHeader, + UserAgent: userAgent, + Timeout: timeout, + Extra: extra, + FlareSolverrURL: flareSolverrURL, } } diff --git a/internal/service/site_adapter.go b/internal/service/site_adapter.go index 5477a1d..d920451 100644 --- a/internal/service/site_adapter.go +++ b/internal/service/site_adapter.go @@ -13,19 +13,23 @@ import ( "strings" "time" + "github.com/ShukeBta/MediaStationGo/internal/helper" "github.com/ShukeBta/MediaStationGo/internal/model" ) // SiteConfig 站点配置(从 model.Site 解密后的纯文本)。 type SiteConfig struct { - Name string - Type string - URL string - AuthType string - Cookie string - APIKey string - AuthHeader string - Extra map[string]string // JSON 扩展配置 + Name string + Type string + URL string + AuthType string + Cookie string + APIKey string + AuthHeader string + UserAgent string // 自定义 User-Agent + Timeout time.Duration // 请求超时 + Extra map[string]string // JSON 扩展配置 + FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF) } // SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。 @@ -110,7 +114,12 @@ func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, bo req.Header.Set("Cookie", cfg.Cookie) } 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) } 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 } // 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) { + // ── 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) if err != nil { return nil, 0, err @@ -176,7 +207,7 @@ func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) erro } 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") } if httpResp.StatusCode != http.StatusOK { @@ -1022,7 +1053,7 @@ func (a *DiscuzAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error if err != nil { 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") } if status != http.StatusOK { diff --git a/scripts/install-ffmpeg.ps1 b/scripts/install-ffmpeg.ps1 new file mode 100644 index 0000000..89039f8 --- /dev/null +++ b/scripts/install-ffmpeg.ps1 @@ -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