package util import ( "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "strconv" "strings" "kenaito-media-picker/vars" "github.com/redmask-hb/GoSimplePrint/goPrint" uuid "github.com/satori/go.uuid" ) // FFprobeExeName 内嵌解码器文件名 const FFprobeExeName = "ffprobe.exe" // FFprobeExePath 返回带当前目录前缀的可执行路径(Windows/Unix均可直接执行) func FFprobeExePath() string { return "." + string(os.PathSeparator) + FFprobeExeName } // ReleaseFFprobe 释放内嵌的ffprobe到当前目录 func ReleaseFFprobe() error { return WriteByteArraysToFile(vars.VideoDecodeToolWin64, FFprobeExeName) } // RemoveFFprobe 删除当前目录下的ffprobe func RemoveFFprobe() { if CheckFileIsExist(FFprobeExeName) { _ = os.Remove(FFprobeExeName) } } // StreamInfo ffprobe流信息 type StreamInfo struct { Index int `json:"index"` CodecType string `json:"codec_type"` CodecName string `json:"codec_name"` Width int `json:"width"` Height int `json:"height"` } // FormatInfo ffprobe容器信息 type FormatInfo struct { Duration string `json:"duration"` Format string `json:"format_name"` } // ProbeResult ffprobe探测结果 type ProbeResult struct { Streams []StreamInfo `json:"streams"` Format FormatInfo `json:"format"` raw string } // VideoResolution 返回第一个有效的视频分辨率 func (p *ProbeResult) VideoResolution() (int, int) { for _, s := range p.Streams { if s.CodecType == "video" && s.Width > 0 && s.Height > 0 { return s.Width, s.Height } } return 0, 0 } // HasAudio 是否包含音频流 func (p *ProbeResult) HasAudio() bool { for _, s := range p.Streams { if s.CodecType == "audio" { return true } } return false } // VideoCodec 第一个视频流的编码名称 func (p *ProbeResult) VideoCodec() string { for _, s := range p.Streams { if s.CodecType == "video" { return s.CodecName } } return "" } // AudioCodec 第一个音频流的编码名称 func (p *ProbeResult) AudioCodec() string { for _, s := range p.Streams { if s.CodecType == "audio" { return s.CodecName } } return "" } // FormatName 容器格式名称 func (p *ProbeResult) FormatName() string { return p.Format.Format } // RawJSON 原始ffprobe JSON输出 func (p *ProbeResult) RawJSON() string { return p.raw } // DurationSeconds 容器时长(秒),无法解析时返回0 func (p *ProbeResult) DurationSeconds() float64 { d, err := strconv.ParseFloat(p.Format.Duration, 64) if err != nil { return 0 } return d } // ProbeMediaFile 用ffprobe探测媒体文件,一次调用同时获取视频/音频流与时长 func ProbeMediaFile(ffprobeExecPath string, filePath string) (*ProbeResult, error) { cmd := exec.Command(ffprobeExecPath, "-v", "error", "-show_entries", "stream=index,codec_type,codec_name,width,height:format=duration,format_name", "-of", "json", filePath) output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("ffprobe failed: %w", err) } var result ProbeResult if err := json.Unmarshal(output, &result); err != nil { return nil, fmt.Errorf("failed to parse ffprobe output: %w", err) } result.raw = string(output) return &result, nil } // IsPotentialMedia 判断是否可能是媒体文件(交由ffprobe进一步确认) func IsPotentialMedia(fileMime string) bool { if fileMime == "" || fileMime == "application/octet-stream" { return true } if strings.HasPrefix(fileMime, "text/") { return false } if strings.HasPrefix(fileMime, "video/") || strings.HasPrefix(fileMime, "audio/") || strings.HasPrefix(fileMime, "image/") { return true } switch fileMime { case "application/zip", "application/gzip", "application/x-gzip", "application/pdf", "application/json", "application/xml", "application/x-httpd-php", "application/x-sh", "application/javascript", "application/x-7z-compressed", "application/x-rar-compressed", "application/x-tar", "application/x-bzip2", "application/x-archive", "application/vnd.sqlite3", "application/vnd.microsoft.portable-executable", "application/x-dosexec", "application/x-executable", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint": return false } return true } // DoMoveFileToDir 批量移动文件到目录 func DoMoveFileToDir(filePathList []string, videoDirPath string) { total := len(filePathList) var count = 0 bar := goPrint.NewBar(100) bar.SetNotice("=== 移动文件到目录:") bar.SetGraph(">") pathSeparator := string(os.PathSeparator) for _, videoFilePath := range filePathList { moveFileToDir(videoFilePath, videoDirPath+pathSeparator) count = count + 1 bar.PrintBar(CalcPercentage(count, total)) } bar.PrintEnd("=== Finish") } // 移动文件到目录,跨文件系统时自动 fallback 为 copy+delete,目标文件名使用 uuid+原文件名防止同名覆盖 func moveFileToDir(sourceFilePath string, targetDirectory string) bool { fileName := strings.ReplaceAll(uuid.NewV4().String(), "-", "") + "_" + filepath.Base(sourceFilePath) targetFilePath := targetDirectory + fileName err := os.Rename(sourceFilePath, targetFilePath) if err == nil { return true } // 跨文件系统等场景:fallback copy + delete if err := copyFile(sourceFilePath, targetFilePath); err != nil { fmt.Printf("=== 复制文件失败, 源: %s, 目标: %s, 错误: %v\n", sourceFilePath, targetFilePath, err) return false } if err := os.Remove(sourceFilePath); err != nil { fmt.Printf("=== 删除源文件失败: %s, 错误: %v\n", sourceFilePath, err) // 复制成功但删除失败,文件已存在于两处,返回 true 表示目标已就位 } return true } // copyFile 逐块复制文件 func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() if _, err := io.Copy(out, in); err != nil { return err } return out.Close() }