396 lines
10 KiB
Go
396 lines
10 KiB
Go
package service
|
||
|
||
import (
|
||
"encoding/binary"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"net/http"
|
||
neturl "net/url"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/QuantumNous/new-api/common"
|
||
)
|
||
|
||
type VideoMetadata struct {
|
||
DurationSec float64
|
||
Width int
|
||
Height int
|
||
HasAudio bool
|
||
}
|
||
|
||
type ffprobeOutput struct {
|
||
Streams []struct {
|
||
CodecType string `json:"codec_type"`
|
||
Width int `json:"width"`
|
||
Height int `json:"height"`
|
||
} `json:"streams"`
|
||
Format struct {
|
||
Duration string `json:"duration"`
|
||
} `json:"format"`
|
||
}
|
||
|
||
var (
|
||
ffprobeResolveOnce sync.Once
|
||
ffprobePathCached string
|
||
ffprobeExtractOnce sync.Once
|
||
ffprobeExtracted string
|
||
)
|
||
|
||
func ProbeVideoMetadataFromURL(url string) (*VideoMetadata, error) {
|
||
trimmed := strings.TrimSpace(url)
|
||
if trimmed == "" {
|
||
return nil, fmt.Errorf("empty video url")
|
||
}
|
||
if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
|
||
return nil, fmt.Errorf("unsupported video url")
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||
defer cancel()
|
||
if meta, err := probeVideoMetadataByFFprobe(ctx, trimmed); err == nil {
|
||
return meta, nil
|
||
} else {
|
||
// ffprobe 不可用或探测失败时,回退到纯 HTTP 头部解析(MP4/MOV)。
|
||
if fallback, fbErr := probeVideoMetadataByHTTPRange(trimmed); fbErr == nil {
|
||
return fallback, nil
|
||
} else {
|
||
if isFFprobeUnavailableError(err) {
|
||
return nil, fmt.Errorf("ffprobe unavailable and HTTP fallback failed: %w", fbErr)
|
||
}
|
||
return nil, fmt.Errorf("ffprobe probe failed: %v; HTTP fallback failed: %w", err, fbErr)
|
||
}
|
||
}
|
||
}
|
||
|
||
func probeVideoMetadataByFFprobe(ctx context.Context, videoURL string) (*VideoMetadata, error) {
|
||
ffprobeCmd := resolveFFprobeCommand()
|
||
// ffprobe is required for robust remote MP4/MOV metadata parsing.
|
||
cmd := exec.CommandContext(ctx, ffprobeCmd,
|
||
"-v", "error",
|
||
"-show_entries", "stream=codec_type,width,height:format=duration",
|
||
"-of", "json",
|
||
videoURL,
|
||
)
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var parsed ffprobeOutput
|
||
if err := common.Unmarshal(out, &parsed); err != nil {
|
||
return nil, err
|
||
}
|
||
meta := &VideoMetadata{}
|
||
for _, s := range parsed.Streams {
|
||
switch strings.ToLower(strings.TrimSpace(s.CodecType)) {
|
||
case "video":
|
||
if s.Width > 0 {
|
||
meta.Width = s.Width
|
||
}
|
||
if s.Height > 0 {
|
||
meta.Height = s.Height
|
||
}
|
||
case "audio":
|
||
meta.HasAudio = true
|
||
}
|
||
}
|
||
if parsed.Format.Duration != "" {
|
||
if d, err := strconv.ParseFloat(strings.TrimSpace(parsed.Format.Duration), 64); err == nil && d > 0 {
|
||
meta.DurationSec = d
|
||
}
|
||
}
|
||
if meta.DurationSec <= 0 || meta.Width <= 0 || meta.Height <= 0 {
|
||
return nil, fmt.Errorf("insufficient video metadata")
|
||
}
|
||
meta.DurationSec = math.Ceil(meta.DurationSec*1000) / 1000
|
||
return meta, nil
|
||
}
|
||
|
||
func isFFprobeUnavailableError(err error) bool {
|
||
var execErr *exec.Error
|
||
if errors.As(err, &execErr) {
|
||
return true
|
||
}
|
||
if strings.Contains(strings.ToLower(err.Error()), "executable file not found") {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
type mp4Box struct {
|
||
boxType string
|
||
start int
|
||
size int
|
||
payload int
|
||
}
|
||
|
||
func probeVideoMetadataByHTTPRange(videoURL string) (*VideoMetadata, error) {
|
||
if _, err := neturl.ParseRequestURI(videoURL); err != nil {
|
||
return nil, fmt.Errorf("invalid video url: %w", err)
|
||
}
|
||
client := http.Client{Timeout: 10 * time.Second}
|
||
req, err := http.NewRequest(http.MethodGet, videoURL, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Range", "bytes=0-204800")
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("unexpected http status: %d", resp.StatusCode)
|
||
}
|
||
data, err := io.ReadAll(io.LimitReader(resp.Body, 205824))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(data) < 12 {
|
||
return nil, fmt.Errorf("insufficient data for mp4 parsing")
|
||
}
|
||
meta := &VideoMetadata{}
|
||
moov, ok := findFirstBox(data, 0, len(data), "moov")
|
||
if !ok {
|
||
return nil, fmt.Errorf("moov box not found in range payload")
|
||
}
|
||
if dur, ok := parseMvhdDuration(data, moov.payload, moov.start+moov.size); ok {
|
||
meta.DurationSec = dur
|
||
}
|
||
tracks := findBoxes(data, moov.payload, moov.start+moov.size, "trak")
|
||
for _, trak := range tracks {
|
||
handlerType := parseTrackHandlerType(data, trak.payload, trak.start+trak.size)
|
||
switch handlerType {
|
||
case "soun":
|
||
meta.HasAudio = true
|
||
case "vide":
|
||
if w, h, ok := parseTrackVideoSize(data, trak.payload, trak.start+trak.size); ok {
|
||
meta.Width = w
|
||
meta.Height = h
|
||
}
|
||
}
|
||
}
|
||
if meta.DurationSec <= 0 || meta.Width <= 0 || meta.Height <= 0 {
|
||
return nil, fmt.Errorf("insufficient video metadata from HTTP range")
|
||
}
|
||
meta.DurationSec = math.Ceil(meta.DurationSec*1000) / 1000
|
||
return meta, nil
|
||
}
|
||
|
||
func findFirstBox(data []byte, start, end int, target string) (mp4Box, bool) {
|
||
boxes := findBoxes(data, start, end, target)
|
||
if len(boxes) == 0 {
|
||
return mp4Box{}, false
|
||
}
|
||
return boxes[0], true
|
||
}
|
||
|
||
func findBoxes(data []byte, start, end int, target string) []mp4Box {
|
||
boxes := make([]mp4Box, 0)
|
||
cursor := start
|
||
for cursor+8 <= end && cursor+8 <= len(data) {
|
||
size := int(binary.BigEndian.Uint32(data[cursor : cursor+4]))
|
||
boxType := string(data[cursor+4 : cursor+8])
|
||
headerLen := 8
|
||
if size == 1 {
|
||
if cursor+16 > len(data) || cursor+16 > end {
|
||
break
|
||
}
|
||
size64 := binary.BigEndian.Uint64(data[cursor+8 : cursor+16])
|
||
if size64 > uint64(^uint(0)>>1) {
|
||
break
|
||
}
|
||
size = int(size64)
|
||
headerLen = 16
|
||
} else if size == 0 {
|
||
size = end - cursor
|
||
}
|
||
if size < headerLen {
|
||
break
|
||
}
|
||
boxEnd := cursor + size
|
||
if boxEnd > end || boxEnd > len(data) {
|
||
break
|
||
}
|
||
if boxType == target {
|
||
boxes = append(boxes, mp4Box{
|
||
boxType: boxType,
|
||
start: cursor,
|
||
size: size,
|
||
payload: cursor + headerLen,
|
||
})
|
||
}
|
||
cursor = boxEnd
|
||
}
|
||
return boxes
|
||
}
|
||
|
||
func parseMvhdDuration(data []byte, start, end int) (float64, bool) {
|
||
mvhd, ok := findFirstBox(data, start, end, "mvhd")
|
||
if !ok || mvhd.payload+20 > len(data) {
|
||
return 0, false
|
||
}
|
||
version := data[mvhd.payload]
|
||
switch version {
|
||
case 1:
|
||
if mvhd.payload+32 > len(data) {
|
||
return 0, false
|
||
}
|
||
timescale := binary.BigEndian.Uint32(data[mvhd.payload+20 : mvhd.payload+24])
|
||
duration := binary.BigEndian.Uint64(data[mvhd.payload+24 : mvhd.payload+32])
|
||
if timescale == 0 {
|
||
return 0, false
|
||
}
|
||
return float64(duration) / float64(timescale), true
|
||
default:
|
||
if mvhd.payload+20 > len(data) {
|
||
return 0, false
|
||
}
|
||
timescale := binary.BigEndian.Uint32(data[mvhd.payload+12 : mvhd.payload+16])
|
||
duration := binary.BigEndian.Uint32(data[mvhd.payload+16 : mvhd.payload+20])
|
||
if timescale == 0 {
|
||
return 0, false
|
||
}
|
||
return float64(duration) / float64(timescale), true
|
||
}
|
||
}
|
||
|
||
func parseTrackHandlerType(data []byte, start, end int) string {
|
||
mdia, ok := findFirstBox(data, start, end, "mdia")
|
||
if !ok {
|
||
return ""
|
||
}
|
||
hdlr, ok := findFirstBox(data, mdia.payload, mdia.start+mdia.size, "hdlr")
|
||
if !ok || hdlr.payload+12 > len(data) {
|
||
return ""
|
||
}
|
||
return string(data[hdlr.payload+8 : hdlr.payload+12])
|
||
}
|
||
|
||
func parseTrackVideoSize(data []byte, start, end int) (int, int, bool) {
|
||
tkhd, ok := findFirstBox(data, start, end, "tkhd")
|
||
if !ok || tkhd.payload+84 > len(data) {
|
||
return 0, 0, false
|
||
}
|
||
version := data[tkhd.payload]
|
||
var widthOffset int
|
||
var heightOffset int
|
||
if version == 1 {
|
||
widthOffset = tkhd.payload + 88
|
||
heightOffset = tkhd.payload + 92
|
||
} else {
|
||
widthOffset = tkhd.payload + 76
|
||
heightOffset = tkhd.payload + 80
|
||
}
|
||
if heightOffset+4 > len(data) {
|
||
return 0, 0, false
|
||
}
|
||
widthFixed := binary.BigEndian.Uint32(data[widthOffset : widthOffset+4])
|
||
heightFixed := binary.BigEndian.Uint32(data[heightOffset : heightOffset+4])
|
||
width := int(widthFixed >> 16)
|
||
height := int(heightFixed >> 16)
|
||
if width <= 0 || height <= 0 {
|
||
return 0, 0, false
|
||
}
|
||
return width, height, true
|
||
}
|
||
|
||
func resolveFFprobeCommand() string {
|
||
ffprobeResolveOnce.Do(func() {
|
||
cmdName := ffprobeCommandName()
|
||
// 0) Embedded ffprobe (enabled by build tag: embed_ffprobe).
|
||
if embeddedPath, ok := ensureEmbeddedFFprobe(cmdName); ok {
|
||
ffprobePathCached = embeddedPath
|
||
common.SysLog(fmt.Sprintf("video metadata: using embedded ffprobe: %s", embeddedPath))
|
||
return
|
||
}
|
||
// 1) Prefer side-by-side bundled binaries.
|
||
for _, candidate := range ffprobeLocalCandidates(cmdName) {
|
||
if isExecutableFile(candidate) {
|
||
ffprobePathCached = candidate
|
||
common.SysLog(fmt.Sprintf("video metadata: using bundled ffprobe: %s", candidate))
|
||
return
|
||
}
|
||
}
|
||
// 2) Fallback to PATH.
|
||
if path, err := exec.LookPath(cmdName); err == nil && path != "" {
|
||
ffprobePathCached = path
|
||
common.SysLog(fmt.Sprintf("video metadata: using PATH ffprobe: %s", path))
|
||
return
|
||
}
|
||
// 3) Keep command name; execution will fail and caller will fallback to HTTP parsing.
|
||
ffprobePathCached = cmdName
|
||
common.SysLog("video metadata: ffprobe not found locally or in PATH, using HTTP fallback when needed")
|
||
})
|
||
return ffprobePathCached
|
||
}
|
||
|
||
func ffprobeCommandName() string {
|
||
if runtime.GOOS == "windows" {
|
||
return "ffprobe.exe"
|
||
}
|
||
return "ffprobe"
|
||
}
|
||
|
||
func ffprobeLocalCandidates(cmdName string) []string {
|
||
exePath, err := os.Executable()
|
||
if err != nil || exePath == "" {
|
||
return nil
|
||
}
|
||
exeDir := filepath.Dir(exePath)
|
||
target := fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH)
|
||
return []string{
|
||
filepath.Join(exeDir, cmdName),
|
||
filepath.Join(exeDir, "bin", "ffprobe", target, cmdName),
|
||
filepath.Join(exeDir, "ffprobe", target, cmdName),
|
||
}
|
||
}
|
||
|
||
func isExecutableFile(path string) bool {
|
||
info, err := os.Stat(path)
|
||
if err != nil || info.IsDir() {
|
||
return false
|
||
}
|
||
if runtime.GOOS == "windows" {
|
||
return true
|
||
}
|
||
return info.Mode()&0o111 != 0
|
||
}
|
||
|
||
func ensureEmbeddedFFprobe(cmdName string) (string, bool) {
|
||
ffprobeExtractOnce.Do(func() {
|
||
blob, suggestedName, ok := getEmbeddedFFprobe(runtime.GOOS, runtime.GOARCH)
|
||
if !ok || len(blob) == 0 {
|
||
return
|
||
}
|
||
fileName := strings.TrimSpace(suggestedName)
|
||
if fileName == "" {
|
||
fileName = cmdName
|
||
}
|
||
targetDir := filepath.Join(os.TempDir(), "token-factory", "ffprobe", fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH))
|
||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||
return
|
||
}
|
||
targetPath := filepath.Join(targetDir, fileName)
|
||
if err := os.WriteFile(targetPath, blob, 0o755); err != nil {
|
||
return
|
||
}
|
||
if runtime.GOOS != "windows" {
|
||
_ = os.Chmod(targetPath, 0o755)
|
||
}
|
||
ffprobeExtracted = targetPath
|
||
})
|
||
if ffprobeExtracted == "" {
|
||
return "", false
|
||
}
|
||
return ffprobeExtracted, true
|
||
}
|