// Package probe reads the container and codec facts out of a media file with // ffprobe, so a client can decide for itself whether it can direct-play the // file. // // Vellum never transcodes. The server has no spare CPU, and measured against // real files, playability turned out to be a property of the CLIENT, not the // file: Chrome on Linux plays an MKV carrying H.264 perfectly well, yet cannot // decode an MP4 carrying HEVC at all — it plays the audio track and reports // videoWidth 0. So the server's only job is to expose what is actually inside // the file; each client decides what it can handle. // // ffprobe is OPTIONAL. Vellum runs without it, just with less information: // Available reports whether it is on PATH, and callers skip probing when it is // absent rather than treating that as an error. package probe import ( "bytes" "context" "encoding/json" "fmt" "os/exec" "strconv" "strings" "time" ) // timeout bounds a single ffprobe invocation. A file on a stalled network mount // must never hang a scan, so ffprobe is killed after this and the error is // recorded like any other probe failure. const timeout = 15 * time.Second // Info is the subset of ffprobe facts Vellum stores per file. // // Zero values are meaningful: an empty VCodec means there is no video track // (audio, or a container whose only "video" stream is embedded cover art), and // an empty ACodec means there is no audio track. DurationMS and the dimensions // stay zero when ffprobe could not determine them. type Info struct { DurationMS int64 VCodec string ACodec string Width int Height int } // Available reports whether ffprobe is on PATH. // // Callers use it to skip probing entirely, so Vellum still works on a host with // no ffmpeg installed — the library just carries no codec facts, and every // client is left to try the file and find out. func Available() bool { _, err := exec.LookPath("ffprobe") return err == nil } // File probes one file and returns its codec facts. The returned context // deadline is capped at timeout regardless of the caller's deadline. func File(ctx context.Context, path string) (Info, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() cmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", path, ) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { if ctx.Err() != nil { return Info{}, fmt.Errorf("probe %s: %w", path, ctx.Err()) } msg := strings.TrimSpace(stderr.String()) if msg == "" { msg = err.Error() } return Info{}, fmt.Errorf("probe %s: %s", path, msg) } var out ffprobeOutput if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { return Info{}, fmt.Errorf("probe %s: parse ffprobe output: %w", path, err) } info := Info{} for _, s := range out.Streams { switch s.CodecType { case "video": // Embedded cover art (an mp3's album image) is reported as a video // stream. It is not a playable video track, so ignore it and leave // VCodec empty for audio files. if s.Disposition.AttachedPic != 0 || info.VCodec != "" { continue } info.VCodec = s.CodecName info.Width = s.Width info.Height = s.Height case "audio": if info.ACodec == "" { info.ACodec = s.CodecName } } } // ffprobe reports the container duration in seconds as a decimal string. // It is absent for some live/streamed inputs; leave DurationMS zero then. if d, err := strconv.ParseFloat(out.Format.Duration, 64); err == nil && d > 0 { info.DurationMS = int64(d * 1000) } return info, nil } // ffprobeOutput mirrors only the JSON fields Vellum reads from // `ffprobe -print_format json -show_format -show_streams`. type ffprobeOutput struct { Format struct { Duration string `json:"duration"` } `json:"format"` Streams []struct { CodecType string `json:"codec_type"` CodecName string `json:"codec_name"` Width int `json:"width"` Height int `json:"height"` Disposition struct { AttachedPic int `json:"attached_pic"` } `json:"disposition"` } `json:"streams"` }