ffprobe already runs on every scanned file; now we capture its stderr and
assess integrity from it. assessIntegrity flags a file "damaged" on the
markers that mean the container/bitstream is unusable: invalid_data,
ebml_corrupt, moov_missing, bitstream_corrupt, plus no_duration (a video
stream with non-positive duration = a truncated/incomplete download).
The verdict rides on MediaInfo.Integrity (IntegrityInfo{Damaged,Reason}),
maps onto LibrarySyncItem.{Integrity,IntegrityReason}, and syncs to the web
so a damaged file can be surfaced at rest instead of only blowing up at
playback.
Bumps the scan cache version (1 → 2) so existing entries re-probe once, and
the scanner re-probes any cached entry that has no integrity verdict yet.
363 lines
11 KiB
Go
363 lines
11 KiB
Go
package mediainfo
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// ffprobeOutput matches the JSON structure from `ffprobe -show_streams -show_format`.
|
|
type ffprobeOutput struct {
|
|
Streams []ffprobeStream `json:"streams"`
|
|
Format ffprobeFormat `json:"format"`
|
|
}
|
|
|
|
type ffprobeFormat struct {
|
|
Duration string `json:"duration"`
|
|
}
|
|
|
|
type ffprobeStream struct {
|
|
CodecType string `json:"codec_type"`
|
|
CodecName string `json:"codec_name"`
|
|
Profile string `json:"profile"`
|
|
Channels int `json:"channels"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
BitsPerRaw string `json:"bits_per_raw_sample"`
|
|
PixFmt string `json:"pix_fmt"`
|
|
ColorSpace string `json:"color_space"`
|
|
ColorTransfer string `json:"color_transfer"`
|
|
ColorPrimaries string `json:"color_primaries"`
|
|
RFrameRate string `json:"r_frame_rate"`
|
|
Duration string `json:"duration"`
|
|
Tags map[string]string `json:"tags"`
|
|
Disposition map[string]int `json:"disposition"`
|
|
SideDataList []sideData `json:"side_data_list"`
|
|
}
|
|
|
|
type sideData struct {
|
|
SideDataType string `json:"side_data_type"`
|
|
}
|
|
|
|
// hdrProfiles maps (color_space, color_transfer) to HDR type.
|
|
var hdrProfiles = map[[2]string]string{
|
|
{"bt2020nc", "smpte2084"}: "HDR10",
|
|
{"bt2020nc", "arib-std-b67"}: "HLG",
|
|
}
|
|
|
|
// ExtractMediaInfo runs ffprobe on a file and parses audio, subtitle, and video streams.
|
|
func ExtractMediaInfo(ctx context.Context, ffprobePath, filePath string) (*MediaInfo, error) {
|
|
cmd := exec.CommandContext(ctx, ffprobePath,
|
|
"-v", "error",
|
|
"-print_format", "json",
|
|
"-show_streams",
|
|
"-show_format",
|
|
filePath,
|
|
)
|
|
|
|
var stderr strings.Builder
|
|
cmd.Stderr = &stderr
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
// A remote URL (debrid HLS-from-URL, hueco #2/2b) has no local file to
|
|
// stat — surface ffprobe's own stderr (e.g. "Protocol not found" when the
|
|
// ffmpeg build lacks TLS, or an HTTP error) instead of a misleading
|
|
// "file not found". Only treat a genuine local path as possibly-missing.
|
|
if !strings.Contains(filePath, "://") {
|
|
if _, statErr := os.Stat(filePath); statErr != nil {
|
|
return nil, fmt.Errorf("ffprobe: file not found: %s", filePath)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("ffprobe failed (file=%s): %s", filePath, stderr.String())
|
|
}
|
|
|
|
var data ffprobeOutput
|
|
if err := json.Unmarshal(output, &data); err != nil {
|
|
return nil, fmt.Errorf("ffprobe JSON parse failed: %w", err)
|
|
}
|
|
|
|
mi, perr := parseFFprobeOutput(data)
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
// A corrupt-but-parseable file (e.g. a half-downloaded MKV) returns valid
|
|
// stream JSON and a zero exit, yet ffprobe still logs structural errors to
|
|
// stderr (captured above). Flag it so the library can warn instead of
|
|
// silently shipping a file that won't play.
|
|
if integ := assessIntegrity(stderr.String(), mi); integ != nil {
|
|
mi.Integrity = integ
|
|
}
|
|
return mi, nil
|
|
}
|
|
|
|
// corruptionMarkers are high-confidence ffprobe stderr substrings (lowercased)
|
|
// that indicate a structurally damaged / incompletely-downloaded file, paired
|
|
// with a STABLE code the web maps to localized copy. Kept conservative so
|
|
// healthy files are never flagged — each appears only on real container/
|
|
// bitstream damage, not benign warnings (ffprobe runs at -v error).
|
|
var corruptionMarkers = []struct{ sub, code string }{
|
|
{"invalid data found when processing input", "invalid_data"},
|
|
{"as first byte of an ebml number", "ebml_corrupt"}, // truncated/corrupt MKV
|
|
{"moov atom not found", "moov_missing"}, // truncated MP4
|
|
{"invalid nal unit size", "bitstream_corrupt"},
|
|
{"non-existing pps", "bitstream_corrupt"},
|
|
// NOTE: deliberately NOT matching "error reading header" (ffprobe emits it
|
|
// on transient NFS/network read hiccups — a genuinely unreadable header
|
|
// also exits non-zero → ScanError → item skipped) nor "truncating packet"
|
|
// (printed for healthy MKV/TS with oversized subtitle/PGS packets). Both
|
|
// false-positive on good files; the markers above are structural.
|
|
}
|
|
|
|
// assessIntegrity inspects ffprobe's stderr plus the parsed result and returns
|
|
// a damaged verdict on a high-confidence corruption signal, else nil. The
|
|
// Reason is a stable code (see corruptionMarkers) the web localizes.
|
|
func assessIntegrity(stderr string, mi *MediaInfo) *IntegrityInfo {
|
|
low := strings.ToLower(stderr)
|
|
for _, m := range corruptionMarkers {
|
|
if strings.Contains(low, m.sub) {
|
|
return &IntegrityInfo{Damaged: true, Reason: m.code}
|
|
}
|
|
}
|
|
// A file that carries a video stream but no determinable duration is almost
|
|
// always truncated (the moov/cues holding duration sit at the end of the
|
|
// file). Audio-only items legitimately omit it, so gate on having video.
|
|
if mi != nil && mi.Video != nil && mi.Video.Duration <= 0 {
|
|
return &IntegrityInfo{Damaged: true, Reason: "no_duration"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseFFprobeOutput converts parsed ffprobe JSON into MediaInfo.
|
|
// Separated from ExtractMediaInfo so it can be tested without running ffprobe.
|
|
func parseFFprobeOutput(data ffprobeOutput) (*MediaInfo, error) {
|
|
if len(data.Streams) == 0 {
|
|
return nil, fmt.Errorf("ffprobe returned no streams")
|
|
}
|
|
|
|
var audioTracks []AudioTrack
|
|
var subtitleTracks []SubtitleTrack
|
|
var videoInfo *VideoInfo
|
|
|
|
for _, s := range data.Streams {
|
|
switch s.CodecType {
|
|
case "audio":
|
|
langRaw := tagValue(s.Tags, "language")
|
|
track := AudioTrack{
|
|
Lang: NormalizeLang(langRaw),
|
|
Codec: s.CodecName,
|
|
Channels: s.Channels,
|
|
}
|
|
if title := tagValue(s.Tags, "title"); title != "" {
|
|
track.Title = title
|
|
}
|
|
if s.Disposition["default"] == 1 {
|
|
track.Default = true
|
|
}
|
|
audioTracks = append(audioTracks, track)
|
|
|
|
case "subtitle":
|
|
langRaw := tagValue(s.Tags, "language")
|
|
track := SubtitleTrack{
|
|
Lang: NormalizeLang(langRaw),
|
|
Codec: s.CodecName,
|
|
}
|
|
if title := tagValue(s.Tags, "title"); title != "" {
|
|
track.Title = title
|
|
}
|
|
if s.Disposition["forced"] == 1 {
|
|
track.Forced = true
|
|
}
|
|
subtitleTracks = append(subtitleTracks, track)
|
|
|
|
case "video":
|
|
if videoInfo != nil {
|
|
continue // only first video stream
|
|
}
|
|
vi := &VideoInfo{
|
|
Codec: s.CodecName,
|
|
Width: s.Width,
|
|
Height: s.Height,
|
|
}
|
|
|
|
// Bit depth
|
|
if s.BitsPerRaw != "" {
|
|
if bd, err := strconv.Atoi(s.BitsPerRaw); err == nil {
|
|
vi.BitDepth = bd
|
|
}
|
|
} else if containsAny(s.PixFmt, "10le", "10be", "p010") {
|
|
vi.BitDepth = 10
|
|
} else if containsAny(s.PixFmt, "12le", "12be") {
|
|
vi.BitDepth = 12
|
|
}
|
|
|
|
// HDR detection
|
|
hdrKey := [2]string{s.ColorSpace, s.ColorTransfer}
|
|
if hdr, ok := hdrProfiles[hdrKey]; ok {
|
|
vi.HDR = hdr
|
|
} else if s.ColorTransfer == "smpte2084" {
|
|
vi.HDR = "HDR10"
|
|
} else if s.ColorTransfer == "arib-std-b67" {
|
|
vi.HDR = "HLG"
|
|
}
|
|
|
|
// Dolby Vision via side_data_list
|
|
for _, sd := range s.SideDataList {
|
|
if sd.SideDataType == "DOVI configuration record" {
|
|
if vi.HDR != "" {
|
|
vi.HDR = "DV+" + vi.HDR
|
|
} else {
|
|
vi.HDR = "DV"
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Frame rate from r_frame_rate (e.g., "24000/1001")
|
|
if s.RFrameRate != "" && strings.Contains(s.RFrameRate, "/") {
|
|
parts := strings.SplitN(s.RFrameRate, "/", 2)
|
|
if num, err1 := strconv.ParseFloat(parts[0], 64); err1 == nil {
|
|
if den, err2 := strconv.ParseFloat(parts[1], 64); err2 == nil && den > 0 {
|
|
vi.FrameRate = math.Round(num/den*1000) / 1000
|
|
}
|
|
}
|
|
}
|
|
|
|
// Profile
|
|
if s.Profile != "" {
|
|
vi.Profile = s.Profile
|
|
}
|
|
|
|
// Duration: prefer format.duration, fallback to stream duration
|
|
if dur := parseDuration(data.Format.Duration); dur > 0 {
|
|
vi.Duration = dur
|
|
} else if dur := parseDuration(s.Duration); dur > 0 {
|
|
vi.Duration = dur
|
|
}
|
|
|
|
videoInfo = vi
|
|
}
|
|
}
|
|
|
|
result := &MediaInfo{
|
|
Video: videoInfo,
|
|
}
|
|
if len(audioTracks) > 0 {
|
|
result.Audio = audioTracks
|
|
result.Languages = ComputeLanguages(audioTracks)
|
|
}
|
|
if len(subtitleTracks) > 0 {
|
|
result.Subtitles = subtitleTracks
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ResolveFFprobe finds the ffprobe binary. Search order:
|
|
// 1. Explicit path (--ffprobe flag)
|
|
// 2. FFPROBE_PATH env var
|
|
// 3. "ffprobe" in PATH
|
|
// 4. Adjacent to the current executable
|
|
// 5. Previously downloaded in cache dir
|
|
// 6. Auto-download static binary
|
|
func ResolveFFprobe(explicit string) (string, error) {
|
|
if explicit != "" {
|
|
if _, err := os.Stat(explicit); err == nil {
|
|
return explicit, nil
|
|
}
|
|
return "", fmt.Errorf("ffprobe not found at explicit path: %s", explicit)
|
|
}
|
|
|
|
if envPath := os.Getenv("FFPROBE_PATH"); envPath != "" {
|
|
if _, err := os.Stat(envPath); err == nil {
|
|
return envPath, nil
|
|
}
|
|
}
|
|
|
|
if p, err := exec.LookPath("ffprobe"); err == nil {
|
|
return p, nil
|
|
}
|
|
|
|
if exePath, err := os.Executable(); err == nil {
|
|
name := "ffprobe"
|
|
if runtime.GOOS == "windows" {
|
|
name = "ffprobe.exe"
|
|
}
|
|
adjacent := filepath.Join(filepath.Dir(exePath), name)
|
|
if _, err := os.Stat(adjacent); err == nil {
|
|
return adjacent, nil
|
|
}
|
|
}
|
|
|
|
if cached, err := FFprobeCachePath(); err == nil {
|
|
if _, err := os.Stat(cached); err == nil {
|
|
return cached, nil
|
|
}
|
|
}
|
|
|
|
if p, err := DownloadFFprobe(); err == nil {
|
|
return p, nil
|
|
}
|
|
|
|
// Give an actionable error depending on whether we're running in Docker.
|
|
if isDocker() {
|
|
return "", fmt.Errorf(
|
|
"ffprobe not found and auto-download failed (read-only filesystem?).\n" +
|
|
"Options:\n" +
|
|
" • Use the official image: torrentclaw/unarr (includes ffprobe)\n" +
|
|
" • Set FFPROBE_PATH env var to point to a pre-installed ffprobe binary\n" +
|
|
" • Add to config.toml: [library]\\nffprobe_path = \"/path/to/ffprobe\"",
|
|
)
|
|
}
|
|
return "", fmt.Errorf(
|
|
"ffprobe not found and auto-download failed.\n" +
|
|
"Options:\n" +
|
|
" • Install ffmpeg: sudo apt install ffmpeg (or brew install ffmpeg)\n" +
|
|
" • Set FFPROBE_PATH env var to point to the ffprobe binary\n" +
|
|
" • Add to config.toml: [library]\\nffprobe_path = \"/path/to/ffprobe\"",
|
|
)
|
|
}
|
|
|
|
// isDocker reports whether the process is running inside a Docker container.
|
|
func isDocker() bool {
|
|
_, err := os.Stat("/.dockerenv")
|
|
return err == nil
|
|
}
|
|
|
|
// tagValue gets a tag value case-insensitively.
|
|
func tagValue(tags map[string]string, key string) string {
|
|
if v, ok := tags[key]; ok {
|
|
return v
|
|
}
|
|
if v, ok := tags[strings.ToUpper(key)]; ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func containsAny(s string, substrs ...string) bool {
|
|
for _, sub := range substrs {
|
|
if strings.Contains(s, sub) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// parseDuration converts a duration string (e.g. "7423.500000") to float64 seconds.
|
|
func parseDuration(s string) float64 {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
d, err := strconv.ParseFloat(s, 64)
|
|
if err != nil || d <= 0 {
|
|
return 0
|
|
}
|
|
return math.Round(d*1000) / 1000
|
|
}
|