package media import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" ) type AudioStream struct { Index int `json:"index"` // global stream index AudioOrder int `json:"audioOrder"` // 0-based audio track index (0:a:) Codec string `json:"codec"` Channels int `json:"channels"` ChannelLayout string `json:"channelLayout"` SampleRate string `json:"sampleRate"` Language string `json:"language"` Title string `json:"title"` } type VideoInfo struct { Path string `json:"path"` FileName string `json:"fileName"` Extension string `json:"extension"` Container string `json:"container"` SizeBytes int64 `json:"sizeBytes"` Duration float64 `json:"duration"` BitRate int64 `json:"bitRate"` Width int `json:"width"` Height int `json:"height"` FPS float64 `json:"fps"` VideoCodec string `json:"videoCodec"` PixFmt string `json:"pixFmt"` AudioStreams []AudioStream `json:"audioStreams"` BrowserNative bool `json:"browserNative"` } type rawFFprobe struct { Format struct { Filename string `json:"filename"` FormatName string `json:"format_name"` Duration string `json:"duration"` Size string `json:"size"` BitRate string `json:"bit_rate"` } `json:"format"` Streams []struct { Index int `json:"index"` CodecType string `json:"codec_type"` CodecName string `json:"codec_name"` Width int `json:"width"` Height int `json:"height"` PixFmt string `json:"pix_fmt"` AvgFrameRate string `json:"avg_frame_rate"` RFrameRate string `json:"r_frame_rate"` Channels int `json:"channels"` ChannelLayout string `json:"channel_layout"` SampleRate string `json:"sample_rate"` Duration string `json:"duration"` Tags map[string]string `json:"tags"` } `json:"streams"` } func Probe(ffprobeBin, inputPath string) (*VideoInfo, error) { if ffprobeBin == "" { return nil, fmt.Errorf("ffprobe binary not found") } st, err := os.Stat(inputPath) if err != nil { return nil, fmt.Errorf("cannot access file: %w", err) } if st.IsDir() { return nil, fmt.Errorf("path is a directory") } args := []string{ "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", inputPath, } cmd := exec.Command(ffprobeBin, args...) hideWindow(cmd) out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("ffprobe failed: %w", err) } info, err := ParseProbeJSON(out, inputPath, st.Size()) if err != nil { return nil, err } return info, nil } func ParseProbeJSON(data []byte, inputPath string, fileSize int64) (*VideoInfo, error) { var raw rawFFprobe if err := json.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("invalid ffprobe json: %w", err) } info := &VideoInfo{ Path: inputPath, FileName: filepath.Base(inputPath), Extension: strings.ToLower(filepath.Ext(inputPath)), Container: raw.Format.FormatName, SizeBytes: fileSize, } if d, err := strconv.ParseFloat(raw.Format.Duration, 64); err == nil && d > 0 { info.Duration = d } if sz, err := strconv.ParseInt(raw.Format.Size, 10, 64); err == nil && sz > 0 { info.SizeBytes = sz } if br, err := strconv.ParseInt(raw.Format.BitRate, 10, 64); err == nil && br > 0 { info.BitRate = br } audioOrder := 0 hasVideo := false for _, s := range raw.Streams { switch s.CodecType { case "video": if !hasVideo { hasVideo = true info.Width = s.Width info.Height = s.Height info.VideoCodec = strings.ToLower(s.CodecName) info.PixFmt = s.PixFmt info.FPS = parseRationalFPS(s.AvgFrameRate) if info.FPS <= 0 { info.FPS = parseRationalFPS(s.RFrameRate) } if info.Duration <= 0 { if d, err := strconv.ParseFloat(s.Duration, 64); err == nil && d > 0 { info.Duration = d } } } case "audio": lang := "" title := "" for k, v := range s.Tags { lk := strings.ToLower(k) if lk == "language" { lang = v } else if lk == "title" { title = v } } info.AudioStreams = append(info.AudioStreams, AudioStream{ Index: s.Index, AudioOrder: audioOrder, Codec: strings.ToLower(s.CodecName), Channels: s.Channels, ChannelLayout: s.ChannelLayout, SampleRate: s.SampleRate, Language: lang, Title: title, }) audioOrder++ } } if !hasVideo { return nil, fmt.Errorf("no video stream found in %s", filepath.Base(inputPath)) } info.BrowserNative = isBrowserNative(info) return info, nil } func parseRationalFPS(expr string) float64 { expr = strings.TrimSpace(expr) if expr == "" || expr == "0/0" { return 0 } parts := strings.Split(expr, "/") if len(parts) == 1 { v, err := strconv.ParseFloat(parts[0], 64) if err != nil { return 0 } return v } if len(parts) == 2 { num, err1 := strconv.ParseFloat(parts[0], 64) den, err2 := strconv.ParseFloat(parts[1], 64) if err1 != nil || err2 != nil || den == 0 { return 0 } return num / den } return 0 } func IsH264BrowserNativePixFmt(pixFmt string) bool { p := strings.ToLower(strings.TrimSpace(pixFmt)) return p == "" || p == "yuv420p" || p == "yuvj420p" } func isBrowserNative(info *VideoInfo) bool { extOK := info.Extension == ".mp4" || info.Extension == ".m4v" || info.Extension == ".webm" if !extOK { return false } vOK := info.VideoCodec == "h264" || info.VideoCodec == "vp8" || info.VideoCodec == "vp9" || info.VideoCodec == "av1" if !vOK { return false } if info.VideoCodec == "h264" && !IsH264BrowserNativePixFmt(info.PixFmt) { return false } // If multiple audio tracks exist, browser plays only track 0; if user wants to preview natively it's fine, // we still treat single-compatible audio as native. for _, a := range info.AudioStreams { switch a.Codec { case "aac", "mp3", "opus", "vorbis", "flac": default: return false } } return true }