From 81e42a5b8a571c4c5299dedf4d57c6cd7563e502 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:22:46 +0800 Subject: [PATCH] split ffprobe parsing helpers --- internal/service/ffprobe.go | 78 ---------------------------- internal/service/ffprobe_parse.go | 84 +++++++++++++++++++++++++++++++ internal/service/ffprobe_test.go | 27 ++++++++++ 3 files changed, 111 insertions(+), 78 deletions(-) create mode 100644 internal/service/ffprobe_parse.go diff --git a/internal/service/ffprobe.go b/internal/service/ffprobe.go index 21e8852..76321d3 100644 --- a/internal/service/ffprobe.go +++ b/internal/service/ffprobe.go @@ -9,12 +9,9 @@ package service import ( "context" - "encoding/json" "errors" "fmt" "os/exec" - "regexp" - "strconv" "strings" "sync" "time" @@ -216,78 +213,3 @@ func ffmpegHeaderText(headers map[string]string) string { } return b.String() } - -// rawProbe mirrors the relevant fields of `ffprobe -show_format -show_streams`. -type rawProbe struct { - Format struct { - Duration string `json:"duration"` - FormatName string `json:"format_name"` - } `json:"format"` - Streams []struct { - CodecType string `json:"codec_type"` - CodecName string `json:"codec_name"` - Width int `json:"width"` - Height int `json:"height"` - } `json:"streams"` -} - -func parseProbeJSON(data []byte) (*ProbeResult, error) { - var raw rawProbe - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("parse ffprobe json: %w", err) - } - res := &ProbeResult{Container: raw.Format.FormatName} - if d, err := strconv.ParseFloat(raw.Format.Duration, 64); err == nil { - res.DurationSec = int(d) - } - for _, s := range raw.Streams { - switch s.CodecType { - case "video": - if res.VideoCodec == "" { - res.VideoCodec = s.CodecName - res.Width = s.Width - res.Height = s.Height - } - case "audio": - if res.AudioCodec == "" { - res.AudioCodec = s.CodecName - } - } - } - return res, nil -} - -var ( - ffmpegDurationRE = regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`) - ffmpegInputRE = regexp.MustCompile(`Input #\d+,\s*(.+?),\s*from`) - ffmpegVideoRE = regexp.MustCompile(`Video:\s*([^,\s]+).*?(\d{2,5})x(\d{2,5})`) - ffmpegAudioRE = regexp.MustCompile(`Audio:\s*([^,\s]+)`) -) - -func parseFFmpegProbeText(text string) *ProbeResult { - res := &ProbeResult{} - if match := ffmpegInputRE.FindStringSubmatch(text); len(match) == 2 { - res.Container = strings.TrimSpace(match[1]) - } - if match := ffmpegDurationRE.FindStringSubmatch(text); len(match) == 4 { - hours, _ := strconv.Atoi(match[1]) - minutes, _ := strconv.Atoi(match[2]) - seconds, _ := strconv.ParseFloat(match[3], 64) - res.DurationSec = hours*3600 + minutes*60 + int(seconds) - } - for _, line := range strings.Split(text, "\n") { - if res.VideoCodec == "" { - if match := ffmpegVideoRE.FindStringSubmatch(line); len(match) == 4 { - res.VideoCodec = strings.TrimSpace(match[1]) - res.Width, _ = strconv.Atoi(match[2]) - res.Height, _ = strconv.Atoi(match[3]) - } - } - if res.AudioCodec == "" { - if match := ffmpegAudioRE.FindStringSubmatch(line); len(match) == 2 { - res.AudioCodec = strings.TrimSpace(match[1]) - } - } - } - return res -} diff --git a/internal/service/ffprobe_parse.go b/internal/service/ffprobe_parse.go new file mode 100644 index 0000000..eb20e83 --- /dev/null +++ b/internal/service/ffprobe_parse.go @@ -0,0 +1,84 @@ +package service + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" +) + +// rawProbe mirrors the relevant fields of `ffprobe -show_format -show_streams`. +type rawProbe struct { + Format struct { + Duration string `json:"duration"` + FormatName string `json:"format_name"` + } `json:"format"` + Streams []struct { + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + Width int `json:"width"` + Height int `json:"height"` + } `json:"streams"` +} + +func parseProbeJSON(data []byte) (*ProbeResult, error) { + var raw rawProbe + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse ffprobe json: %w", err) + } + res := &ProbeResult{Container: raw.Format.FormatName} + if d, err := strconv.ParseFloat(raw.Format.Duration, 64); err == nil { + res.DurationSec = int(d) + } + for _, s := range raw.Streams { + switch s.CodecType { + case "video": + if res.VideoCodec == "" { + res.VideoCodec = s.CodecName + res.Width = s.Width + res.Height = s.Height + } + case "audio": + if res.AudioCodec == "" { + res.AudioCodec = s.CodecName + } + } + } + return res, nil +} + +var ( + ffmpegDurationRE = regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`) + ffmpegInputRE = regexp.MustCompile(`Input #\d+,\s*(.+?),\s*from`) + ffmpegVideoRE = regexp.MustCompile(`Video:\s*([^,\s]+).*?(\d{2,5})x(\d{2,5})`) + ffmpegAudioRE = regexp.MustCompile(`Audio:\s*([^,\s]+)`) +) + +func parseFFmpegProbeText(text string) *ProbeResult { + res := &ProbeResult{} + if match := ffmpegInputRE.FindStringSubmatch(text); len(match) == 2 { + res.Container = strings.TrimSpace(match[1]) + } + if match := ffmpegDurationRE.FindStringSubmatch(text); len(match) == 4 { + hours, _ := strconv.Atoi(match[1]) + minutes, _ := strconv.Atoi(match[2]) + seconds, _ := strconv.ParseFloat(match[3], 64) + res.DurationSec = hours*3600 + minutes*60 + int(seconds) + } + for _, line := range strings.Split(text, "\n") { + if res.VideoCodec == "" { + if match := ffmpegVideoRE.FindStringSubmatch(line); len(match) == 4 { + res.VideoCodec = strings.TrimSpace(match[1]) + res.Width, _ = strconv.Atoi(match[2]) + res.Height, _ = strconv.Atoi(match[3]) + } + } + if res.AudioCodec == "" { + if match := ffmpegAudioRE.FindStringSubmatch(line); len(match) == 2 { + res.AudioCodec = strings.TrimSpace(match[1]) + } + } + } + return res +} diff --git a/internal/service/ffprobe_test.go b/internal/service/ffprobe_test.go index 160f952..c1bde87 100644 --- a/internal/service/ffprobe_test.go +++ b/internal/service/ffprobe_test.go @@ -86,3 +86,30 @@ func TestApplyRuntimeSettingFFprobeMaxConcurrent(t *testing.T) { t.Fatalf("FFprobeMaxConcurrent = %d, want clamp 1", cfg.App.FFprobeMaxConcurrent) } } + +func TestParseProbeJSONExtractsPrimaryStreams(t *testing.T) { + got, err := parseProbeJSON([]byte(`{ + "format": {"duration": "125.900000", "format_name": "matroska,webm"}, + "streams": [ + {"codec_type": "video", "codec_name": "hevc", "width": 3840, "height": 2160}, + {"codec_type": "audio", "codec_name": "eac3"}, + {"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080} + ] + }`)) + if err != nil { + t.Fatalf("parseProbeJSON: %v", err) + } + if got.DurationSec != 125 || got.Container != "matroska,webm" || got.VideoCodec != "hevc" || got.AudioCodec != "eac3" || got.Width != 3840 || got.Height != 2160 { + t.Fatalf("parsed probe = %+v", got) + } +} + +func TestParseFFmpegProbeTextExtractsFallbackMetadata(t *testing.T) { + got := parseFFmpegProbeText(`Input #0, matroska,webm, from 'movie.mkv': + Duration: 01:02:03.45, start: 0.000000, bitrate: N/A + Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x804 + Stream #0:1: Audio: aac, 48000 Hz, stereo`) + if got.DurationSec != 3723 || got.Container != "matroska,webm" || got.VideoCodec != "h264" || got.AudioCodec != "aac" || got.Width != 1920 || got.Height != 804 { + t.Fatalf("parsed ffmpeg text = %+v", got) + } +}