* fix(scanner): reject durations that imply an impossible bitrate The duration-plausibility rule only rejected videos of 10 seconds or less, so a feature film that probed as 61 seconds passed untouched and persisted. Clients then had nothing trustworthy to anchor on: Android's grow-only duration ratchet has no floor to hold when the catalog value is wrong, so the playback engine's growing-HLS-window duration won and a 90-minute movie displayed as ~1 minute. Size and duration together pin an implied bitrate, which separates the two cases the absolute floor conflates. A genuine short clip has an ordinary bitrate; a 100 GB file claiming 61 seconds implies ~13 Gbps. The ceiling sits far above any real medium, so legitimate content cannot trip it — and unlike the absolute floor, it does not false-positive on a genuine high-bitrate short. Also bump the repair-rule revision marker so rows judged by the previous, weaker rule are re-checked once under this one. Without that bump an improved rule never reaches the rows it was written for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(playback): publish source runtime in v3 plans and stop faking the copy seek window Two defects with one root: a v3 plan described where playback sits without ever stating how long the media is. Add source.duration_seconds. It is the file's full runtime, never `total - source_start` and never adjusted by timeline_offset_seconds, and it is omitted rather than null when unknown — clients that coerce null to a numeric default would read it as zero, the exact value this field exists to stop them inventing. It is set in SourceDescriptorFromFileV3, the single place every delivery already flows through, so direct play, progressive remux, HLS remux and HLS transcode all carry it. Until now the v3 plan omitted duration entirely, so clients fell back to the playback engine. On an HLS copy remux the server intentionally serves FFmpeg's still-growing playlist, so the engine reports the length produced so far. With no server-supplied runtime to anchor on, a feature film played back as a couple of minutes. The legacy protocol already answered this correctly via fileDurationSeconds; this restores parity. Separately, the copy branch published seek_window_end_seconds as the media runtime. That made the window look *complete*, which clients read as proof that any target inside it is locally seekable, so they native-seek past the produced head of a growing playlist instead of asking for a reanchor. Leave the end open: an incomplete window plus can_seek_anywhere=false routes every seek through the server, which is what legacy did before v3 added the bound. Advertise plan_source_duration_v1 so a client can distinguish "this server does not populate the field" from "this server knows the runtime is genuinely unknown" — without it, both look like an absent field and a client cannot tell whether its own catalog fallback is still required. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(web): pair the exit position with the media runtime, not the element duration The player's exit state converts its position to media time but took the duration from the video element, which is player-local. On a remux or transcode stream the element only covers the window produced so far, so the two values live in different coordinate systems. Resuming a movie 50 minutes in makes that concrete: the exit position is ~3060s of media time while the element reports ~120s. The progress cache then evaluates `position >= duration`, marks the item completed, latches the watched badge, and — because completion clears the resume point — resets position to 0. Exiting a resumed movie destroyed the resume point and claimed it had been watched. The server's runtime is authoritative and already expressed in media time, so prefer it and fall back to the element only when no server value exists. The rule moves into mediaTimeline.ts next to the coordinate conversions it depends on, which is also what makes it testable — VideoPlayer itself has no test harness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
855 lines
25 KiB
Go
855 lines
25 KiB
Go
package scanner
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os/exec"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/lang"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// ffprobeOutput represents the top-level JSON output from ffprobe.
|
|
type ffprobeOutput struct {
|
|
Format ffprobeFormat `json:"format"`
|
|
Streams []ffprobeStream `json:"streams"`
|
|
Chapters []ffprobeChapter `json:"chapters"`
|
|
}
|
|
|
|
// ffprobeScalarString accepts ffprobe fields that may be emitted as either
|
|
// JSON strings or numbers depending on codec/container details.
|
|
type ffprobeScalarString string
|
|
|
|
func (s *ffprobeScalarString) UnmarshalJSON(data []byte) error {
|
|
if string(data) == "null" {
|
|
*s = ""
|
|
return nil
|
|
}
|
|
|
|
var str string
|
|
if err := json.Unmarshal(data, &str); err == nil {
|
|
*s = ffprobeScalarString(str)
|
|
return nil
|
|
}
|
|
|
|
var num json.Number
|
|
if err := json.Unmarshal(data, &num); err == nil {
|
|
*s = ffprobeScalarString(num.String())
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("unsupported ffprobe scalar %s", string(data))
|
|
}
|
|
|
|
// ffprobeFormat represents the "format" section of ffprobe JSON output.
|
|
type ffprobeFormat struct {
|
|
Filename string `json:"filename"`
|
|
FormatName string `json:"format_name"`
|
|
FormatLongName string `json:"format_long_name"`
|
|
StartTime string `json:"start_time"`
|
|
Duration string `json:"duration"`
|
|
Size string `json:"size"`
|
|
BitRate string `json:"bit_rate"`
|
|
Tags map[string]string `json:"tags"`
|
|
}
|
|
|
|
// ffprobeStream represents a single stream entry in ffprobe JSON output.
|
|
type ffprobeStream struct {
|
|
Index int `json:"index"`
|
|
CodecName string `json:"codec_name"`
|
|
CodecLongName string `json:"codec_long_name"`
|
|
CodecType string `json:"codec_type"`
|
|
Profile string `json:"profile"`
|
|
Level int `json:"level"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
DisplayAspectRatio string `json:"display_aspect_ratio"`
|
|
FieldOrder string `json:"field_order"`
|
|
AvgFrameRate string `json:"avg_frame_rate"`
|
|
StartTime string `json:"start_time"`
|
|
Duration string `json:"duration"`
|
|
BitRate string `json:"bit_rate"`
|
|
ColorRange string `json:"color_range"`
|
|
ColorTransfer string `json:"color_transfer"`
|
|
ColorPrimaries string `json:"color_primaries"`
|
|
ColorSpace string `json:"color_space"`
|
|
PixFmt string `json:"pix_fmt"`
|
|
Refs int `json:"refs"`
|
|
BitsPerRawSample ffprobeScalarString `json:"bits_per_raw_sample"`
|
|
BitsPerSample ffprobeScalarString `json:"bits_per_sample"`
|
|
Channels int `json:"channels"`
|
|
ChannelLayout string `json:"channel_layout"`
|
|
SampleRate string `json:"sample_rate"`
|
|
Disposition ffprobeDisp `json:"disposition"`
|
|
Tags map[string]string `json:"tags"`
|
|
SideDataList []ffprobeSideData `json:"side_data_list"`
|
|
}
|
|
|
|
type ffprobeChapter struct {
|
|
ID int `json:"id"`
|
|
Start ffprobeScalarString `json:"start"`
|
|
End ffprobeScalarString `json:"end"`
|
|
TimeBase string `json:"time_base"`
|
|
StartTime ffprobeScalarString `json:"start_time"`
|
|
EndTime ffprobeScalarString `json:"end_time"`
|
|
Tags map[string]string `json:"tags"`
|
|
}
|
|
|
|
type ffprobeSideData struct {
|
|
SideDataType string `json:"side_data_type"`
|
|
DVProfile int `json:"dv_profile"`
|
|
DVBlPresent int `json:"dv_bl_present"`
|
|
DVElPresent int `json:"dv_el_present"`
|
|
DVBLCompatID int `json:"dv_bl_signal_compatibility_id"`
|
|
}
|
|
|
|
// ffprobeDisp represents the disposition flags on a stream.
|
|
type ffprobeDisp struct {
|
|
Default int `json:"default"`
|
|
Forced int `json:"forced"`
|
|
AttachedPic int `json:"attached_pic"`
|
|
}
|
|
|
|
// ProbeFile runs ffprobe on the given file and returns parsed ProbeData.
|
|
// ffprobePath is the path to the ffprobe binary. filePath is the media file to probe.
|
|
func ProbeFile(ctx context.Context, ffprobePath string, filePath string) (*ProbeData, error) {
|
|
cmd := exec.CommandContext(ctx, ffprobePath,
|
|
"-v", "quiet",
|
|
"-print_format", "json",
|
|
"-show_format",
|
|
"-show_streams",
|
|
"-show_chapters",
|
|
filePath,
|
|
)
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ffprobe failed for %s: %w", filePath, err)
|
|
}
|
|
|
|
var raw ffprobeOutput
|
|
if err := json.Unmarshal(output, &raw); err != nil {
|
|
return nil, fmt.Errorf("ffprobe JSON parse failed for %s: %w", filePath, err)
|
|
}
|
|
|
|
probe := convertProbeData(&raw)
|
|
if probe.Duration == 0 {
|
|
if frameRate, hasVideo := primaryVideoFrameRate(raw.Streams); hasVideo {
|
|
// A failed or empty packet scan must not discard the codec and
|
|
// track metadata that already parsed successfully: callers persist
|
|
// the partial probe, and the repair layer retries rows whose
|
|
// duration is still unknown.
|
|
if duration, packetErr := probeVideoPacketDuration(ctx, ffprobePath, filePath, frameRate); packetErr == nil && duration > 0 {
|
|
probe.Duration = duration
|
|
}
|
|
}
|
|
}
|
|
|
|
return probe, nil
|
|
}
|
|
|
|
// FFprobePathFromFFmpeg derives the sibling ffprobe binary path from a configured ffmpeg path.
|
|
func FFprobePathFromFFmpeg(ffmpegPath string) string {
|
|
if i := strings.LastIndex(ffmpegPath, "ffmpeg"); i >= 0 {
|
|
ffprobePath := ffmpegPath[:i] + "ffprobe" + ffmpegPath[i+len("ffmpeg"):]
|
|
if ffprobePath != "" && ffprobePath != ffmpegPath {
|
|
return ffprobePath
|
|
}
|
|
}
|
|
return "ffprobe"
|
|
}
|
|
|
|
// convertProbeData transforms raw ffprobe JSON output into ProbeData.
|
|
func convertProbeData(raw *ffprobeOutput) *ProbeData {
|
|
pd := &ProbeData{
|
|
Container: detectContainer(raw.Format.FormatName),
|
|
}
|
|
|
|
if duration, ok := durationFromProbeMetadata(raw); ok {
|
|
pd.Duration = duration
|
|
}
|
|
|
|
// Parse bitrate from format (bps to kbps).
|
|
if raw.Format.BitRate != "" {
|
|
if br, err := strconv.Atoi(raw.Format.BitRate); err == nil {
|
|
pd.Bitrate = br / 1000
|
|
}
|
|
}
|
|
|
|
for _, s := range raw.Streams {
|
|
switch s.CodecType {
|
|
case "video":
|
|
dvProfile := dolbyVisionProfileNumber(s.SideDataList)
|
|
// ffprobe omits unspecified optional fields by default; "unknown" is
|
|
// FFmpeg's canonical name for AVCOL_RANGE_UNSPECIFIED.
|
|
colorRange := firstNonEmpty(s.ColorRange, "unknown")
|
|
track := VideoTrackInfo{
|
|
Title: firstNonEmpty(s.Tags["title"], s.CodecLongName, strings.ToUpper(s.CodecName)),
|
|
Codec: s.CodecName,
|
|
DolbyVision: dolbyVisionProfile(s.SideDataList),
|
|
DVProfile: dvProfile,
|
|
DVBLCompatID: dolbyVisionBLCompatID(s.SideDataList),
|
|
DVELPresent: dolbyVisionELPresent(s.SideDataList),
|
|
DVEnhancementLayer: dolbyVisionEnhancementLayer(dolbyVisionELPresent(s.SideDataList)),
|
|
HDR10Plus: hasHDR10Plus(s.SideDataList),
|
|
Profile: s.Profile,
|
|
Level: s.Level,
|
|
Width: s.Width,
|
|
Height: s.Height,
|
|
AspectRatio: s.DisplayAspectRatio,
|
|
Interlaced: isInterlaced(s.FieldOrder),
|
|
FrameRate: normalizeFrameRate(s.AvgFrameRate),
|
|
Bitrate: parseNumeric(s.BitRate) / 1000,
|
|
VideoRange: videoRangeLabel(s),
|
|
VideoRangeType: videoRangeType(s),
|
|
ColorRange: colorRange,
|
|
ColorPrimaries: s.ColorPrimaries,
|
|
ColorSpace: s.ColorSpace,
|
|
ColorTransfer: s.ColorTransfer,
|
|
BitDepth: models.NormalizeVideoBitDepth(parseBitDepth(s), s.PixFmt, s.Profile),
|
|
PixelFormat: s.PixFmt,
|
|
ReferenceFrames: s.Refs,
|
|
}
|
|
pd.VideoTracks = append(pd.VideoTracks, track)
|
|
if pd.CodecVideo == "" {
|
|
pd.CodecVideo = s.CodecName
|
|
pd.Resolution = mapResolution(s.Width, s.Height)
|
|
pd.HDR = isHDR(s.ColorTransfer) || dvProfile > 0 || track.HDR10Plus
|
|
}
|
|
case "audio":
|
|
track := AudioTrackInfo{
|
|
Title: firstNonEmpty(s.Tags["title"], s.CodecLongName, strings.ToUpper(s.CodecName)),
|
|
EmbeddedTitle: s.Tags["title"],
|
|
Language: lang.Canonical(s.Tags["language"]),
|
|
Codec: s.CodecName,
|
|
Profile: s.Profile,
|
|
Layout: s.ChannelLayout,
|
|
Channels: s.Channels,
|
|
Bitrate: parseNumeric(s.BitRate) / 1000,
|
|
SampleRate: parseNumeric(s.SampleRate),
|
|
BitDepth: parseBitDepth(s),
|
|
Default: s.Disposition.Default == 1,
|
|
}
|
|
pd.AudioTracks = append(pd.AudioTracks, track)
|
|
if pd.CodecAudio == "" {
|
|
pd.CodecAudio = s.CodecName
|
|
pd.AudioChannels = s.Channels
|
|
}
|
|
case "subtitle":
|
|
track := SubtitleTrackInfo{
|
|
Index: s.Index,
|
|
Codec: s.CodecName,
|
|
Language: lang.Canonical(s.Tags["language"]),
|
|
Title: firstNonEmpty(s.Tags["title"], strings.ToUpper(s.CodecName)),
|
|
EmbeddedTitle: s.Tags["title"],
|
|
Resolution: subtitleResolutionLabel(s),
|
|
Forced: s.Disposition.Forced == 1,
|
|
Default: s.Disposition.Default == 1,
|
|
HearingImpaired: dispositionFlag(s.Tags, "hearing_impaired"),
|
|
}
|
|
pd.SubtitleTracks = append(pd.SubtitleTracks, track)
|
|
}
|
|
}
|
|
|
|
pd.Chapters = normalizeChapters(raw.Chapters, pd.Duration)
|
|
pd.FormatTags = normalizeFormatTags(raw.Format.Tags)
|
|
|
|
return pd
|
|
}
|
|
|
|
const (
|
|
maxReasonableMediaDurationSeconds = 100_000
|
|
// Audio-only files (audiobooks, podcasts) legitimately exceed the video
|
|
// ceiling, but still need a cap so malformed containers cannot persist
|
|
// multi-year durations.
|
|
maxReasonableAudioDurationSeconds = 1_000_000
|
|
)
|
|
|
|
// A video duration is implausible when it is either far too short in absolute
|
|
// terms, or when it implies a bitrate no real medium reaches. Both are
|
|
// signatures of malformed container timestamps (and of the legacy probe that
|
|
// divided large durations by one million).
|
|
//
|
|
// The absolute rule alone cannot catch a feature film that probed as, say, 61
|
|
// seconds — well past the floor, yet still wrong by two orders of magnitude.
|
|
// Size and duration together pin an implied bitrate, which separates the two
|
|
// cases the absolute rule conflates: a genuine short clip has an ordinary
|
|
// bitrate, while a 100 GB file claiming 61 seconds implies ~13 Gbps.
|
|
//
|
|
// The ceiling sits far above any real medium — UHD Blu-ray peaks near
|
|
// 150 Mbps and ProRes 4444 XQ at 4K near 500 Mbps — so legitimate content
|
|
// cannot trip it. This also makes the rule safer than the absolute floor
|
|
// alone, which false-positives on a genuine high-bitrate short.
|
|
//
|
|
// The shape is shared with the repair triggers in probe_repair.go and
|
|
// scanner.go so the probe parser and the repair layers cannot drift apart.
|
|
const (
|
|
implausiblyShortVideoMaxSeconds = 10
|
|
implausiblyShortVideoMinBytes = 100 * 1024 * 1024
|
|
implausibleVideoBitrateBps = 1_000_000_000
|
|
)
|
|
|
|
func videoDurationImplausible(durationSeconds float64, sizeBytes int64, hasVideo bool) bool {
|
|
if !hasVideo || durationSeconds <= 0 || sizeBytes <= 0 {
|
|
return false
|
|
}
|
|
if durationSeconds <= implausiblyShortVideoMaxSeconds && sizeBytes >= implausiblyShortVideoMinBytes {
|
|
return true
|
|
}
|
|
return impliedBitrateBps(sizeBytes, durationSeconds) > implausibleVideoBitrateBps
|
|
}
|
|
|
|
// impliedBitrateBps is the bitrate a file's size and duration imply. Callers
|
|
// use it as a duration-sanity signal, not as a real bitrate estimate: it
|
|
// counts container overhead and every stream, which is precisely what makes it
|
|
// a conservative upper bound.
|
|
func impliedBitrateBps(sizeBytes int64, durationSeconds float64) float64 {
|
|
return float64(sizeBytes) * 8 / durationSeconds
|
|
}
|
|
|
|
func durationFromProbeMetadata(raw *ffprobeOutput) (int, bool) {
|
|
if raw == nil {
|
|
return 0, false
|
|
}
|
|
|
|
formatDuration := parseFloat(raw.Format.Duration)
|
|
if !hasVideoStream(raw.Streams) &&
|
|
durationIsPositiveFinite(formatDuration) && formatDuration <= maxReasonableAudioDurationSeconds {
|
|
return truncatedDuration(formatDuration), true
|
|
}
|
|
if durationIsReasonable(formatDuration) && !durationLooksImplausible(raw, formatDuration) {
|
|
return truncatedDuration(formatDuration), true
|
|
}
|
|
|
|
for _, stream := range raw.Streams {
|
|
if !isMainVideoStream(stream) {
|
|
continue
|
|
}
|
|
streamDuration := parseFloat(stream.Duration)
|
|
if durationIsReasonable(streamDuration) && !durationLooksImplausible(raw, streamDuration) {
|
|
return truncatedDuration(streamDuration), true
|
|
}
|
|
duration := durationAfterStart(streamDuration, parseFloat(stream.StartTime))
|
|
if duration > 0 && !durationLooksImplausible(raw, duration) {
|
|
return truncatedDuration(duration), true
|
|
}
|
|
}
|
|
|
|
duration := durationAfterStart(formatDuration, parseFloat(raw.Format.StartTime))
|
|
if duration > 0 && !durationLooksImplausible(raw, duration) {
|
|
return truncatedDuration(duration), true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func durationLooksImplausible(raw *ffprobeOutput, duration float64) bool {
|
|
if raw == nil {
|
|
return false
|
|
}
|
|
size := int64(parseFloat(raw.Format.Size))
|
|
return videoDurationImplausible(duration, size, hasVideoStream(raw.Streams))
|
|
}
|
|
|
|
func durationAfterStart(end, start float64) float64 {
|
|
if start <= 0 || end <= start {
|
|
return 0
|
|
}
|
|
duration := end - start
|
|
if !durationIsReasonable(duration) {
|
|
return 0
|
|
}
|
|
return duration
|
|
}
|
|
|
|
func durationIsReasonable(duration float64) bool {
|
|
return durationIsPositiveFinite(duration) && duration <= maxReasonableMediaDurationSeconds
|
|
}
|
|
|
|
func durationIsPositiveFinite(duration float64) bool {
|
|
return duration > 0 && !math.IsNaN(duration) && !math.IsInf(duration, 0)
|
|
}
|
|
|
|
func roundedDuration(duration float64) int {
|
|
return max(1, int(math.Round(duration)))
|
|
}
|
|
|
|
func truncatedDuration(duration float64) int {
|
|
return max(1, int(duration))
|
|
}
|
|
|
|
// isMainVideoStream reports whether the stream is a real video stream.
|
|
// Embedded cover art (attached_pic) is reported by ffprobe as a video stream
|
|
// but must not drive duration decisions: it would route audiobooks and music
|
|
// through the video duration gauntlet and packet-scan a single still image.
|
|
func isMainVideoStream(stream ffprobeStream) bool {
|
|
return stream.CodecType == "video" && stream.Disposition.AttachedPic == 0
|
|
}
|
|
|
|
func hasVideoStream(streams []ffprobeStream) bool {
|
|
return slices.ContainsFunc(streams, isMainVideoStream)
|
|
}
|
|
|
|
func primaryVideoFrameRate(streams []ffprobeStream) (string, bool) {
|
|
for _, stream := range streams {
|
|
if isMainVideoStream(stream) {
|
|
return stream.AvgFrameRate, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// probeVideoPacketDuration derives a duration for files whose duration
|
|
// metadata is unusable by scanning video packet timestamps. It intentionally
|
|
// demuxes the whole file: the timestamps being repaired are the same ones
|
|
// ffprobe would need for reliable interval seeking, so sampling cannot be
|
|
// trusted here. The repair layer keeps this one-shot per file.
|
|
func probeVideoPacketDuration(
|
|
ctx context.Context,
|
|
ffprobePath string,
|
|
filePath string,
|
|
frameRate string,
|
|
) (int, error) {
|
|
cmd := exec.CommandContext(ctx, ffprobePath,
|
|
"-v", "error",
|
|
"-select_streams", "v:0",
|
|
"-show_entries", "packet=pts_time",
|
|
"-of", "csv=p=0",
|
|
filePath,
|
|
)
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("opening ffprobe packet output: %w", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return 0, fmt.Errorf("starting ffprobe packet scan: %w", err)
|
|
}
|
|
|
|
duration := estimateVideoPacketDuration(stdout, frameRate)
|
|
if err := cmd.Wait(); err != nil {
|
|
return 0, fmt.Errorf("ffprobe packet scan failed for %s: %w", filePath, err)
|
|
}
|
|
return duration, nil
|
|
}
|
|
|
|
func estimateVideoPacketDuration(reader io.Reader, frameRate string) int {
|
|
scanner := bufio.NewScanner(reader)
|
|
packetCount := 0
|
|
minTimestamp := math.Inf(1)
|
|
maxTimestamp := math.Inf(-1)
|
|
|
|
for scanner.Scan() {
|
|
value := strings.TrimSpace(scanner.Text())
|
|
if value == "" {
|
|
continue
|
|
}
|
|
packetCount++
|
|
timestamp, err := strconv.ParseFloat(value, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
minTimestamp = min(minTimestamp, timestamp)
|
|
maxTimestamp = max(maxTimestamp, timestamp)
|
|
}
|
|
|
|
best := 0.0
|
|
if !math.IsInf(minTimestamp, 1) && !math.IsInf(maxTimestamp, -1) {
|
|
span := maxTimestamp - minTimestamp
|
|
if durationIsReasonable(span) {
|
|
best = span
|
|
}
|
|
}
|
|
if fps := parseFrameRate(frameRate); fps > 0 && packetCount > 0 {
|
|
frameDuration := float64(packetCount) / fps
|
|
if durationIsReasonable(frameDuration) && frameDuration > best {
|
|
best = frameDuration
|
|
}
|
|
}
|
|
if best <= 0 {
|
|
return 0
|
|
}
|
|
return roundedDuration(best)
|
|
}
|
|
|
|
// parseFrameRate parses ffprobe's rational frame-rate shape ("30000/1001")
|
|
// or a plain float, returning 0 when unparsable. normalizeFrameRate formats
|
|
// the same parse for persistence; keep the parsing logic here only.
|
|
func parseFrameRate(raw string) float64 {
|
|
raw = strings.TrimSpace(raw)
|
|
parts := strings.SplitN(raw, "/", 2)
|
|
if len(parts) != 2 {
|
|
fps, _ := strconv.ParseFloat(raw, 64)
|
|
return fps
|
|
}
|
|
numerator, err := strconv.ParseFloat(parts[0], 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
denominator, err := strconv.ParseFloat(parts[1], 64)
|
|
if err != nil || denominator == 0 {
|
|
return 0
|
|
}
|
|
return numerator / denominator
|
|
}
|
|
|
|
func parseNumeric(raw string) int {
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
v, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return v
|
|
}
|
|
|
|
func parseFloat(raw string) float64 {
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
value, err := strconv.ParseFloat(raw, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizeChapters(raw []ffprobeChapter, durationSeconds int) []ChapterInfo {
|
|
if len(raw) == 0 {
|
|
return []ChapterInfo{}
|
|
}
|
|
|
|
limit := float64(durationSeconds)
|
|
type chapterRange struct {
|
|
title string
|
|
start float64
|
|
end float64
|
|
}
|
|
|
|
ranges := make([]chapterRange, 0, len(raw))
|
|
for _, chapter := range raw {
|
|
start := parseFloat(string(chapter.StartTime))
|
|
end := parseFloat(string(chapter.EndTime))
|
|
if end <= 0 {
|
|
end = parseFloat(string(chapter.End))
|
|
}
|
|
if start <= 0 {
|
|
start = parseFloat(string(chapter.Start))
|
|
}
|
|
if limit > 0 {
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
if end > limit {
|
|
end = limit
|
|
}
|
|
}
|
|
if end <= start {
|
|
continue
|
|
}
|
|
|
|
title := strings.TrimSpace(firstNonEmpty(
|
|
chapter.Tags["title"],
|
|
chapter.Tags["TITLE"],
|
|
))
|
|
ranges = append(ranges, chapterRange{
|
|
title: title,
|
|
start: start,
|
|
end: end,
|
|
})
|
|
}
|
|
|
|
if len(ranges) == 0 {
|
|
return []ChapterInfo{}
|
|
}
|
|
|
|
slices.SortStableFunc(ranges, func(a, b chapterRange) int {
|
|
switch {
|
|
case a.start < b.start:
|
|
return -1
|
|
case a.start > b.start:
|
|
return 1
|
|
case a.end < b.end:
|
|
return -1
|
|
case a.end > b.end:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
|
|
chapters := make([]ChapterInfo, 0, len(ranges))
|
|
for i, chapter := range ranges {
|
|
end := chapter.end
|
|
if i+1 < len(ranges) && ranges[i+1].start < end {
|
|
end = ranges[i+1].start
|
|
}
|
|
if end <= chapter.start {
|
|
continue
|
|
}
|
|
|
|
title := chapter.title
|
|
if title == "" {
|
|
title = fmt.Sprintf("Chapter %02d", len(chapters)+1)
|
|
}
|
|
chapters = append(chapters, ChapterInfo{
|
|
Index: len(chapters),
|
|
Title: title,
|
|
StartSeconds: chapter.start,
|
|
EndSeconds: end,
|
|
Source: "embedded",
|
|
})
|
|
}
|
|
|
|
return chapters
|
|
}
|
|
|
|
func parseBitDepth(s ffprobeStream) int {
|
|
if v := parseNumeric(string(s.BitsPerRawSample)); v > 0 {
|
|
return v
|
|
}
|
|
return parseNumeric(string(s.BitsPerSample))
|
|
}
|
|
|
|
func normalizeFrameRate(raw string) string {
|
|
if raw == "" || raw == "0/0" {
|
|
return ""
|
|
}
|
|
if !strings.Contains(raw, "/") {
|
|
return raw
|
|
}
|
|
fps := parseFrameRate(raw)
|
|
if fps == 0 {
|
|
return raw
|
|
}
|
|
return strconv.FormatFloat(fps, 'f', 3, 64)
|
|
}
|
|
|
|
func isInterlaced(fieldOrder string) bool {
|
|
switch strings.ToLower(fieldOrder) {
|
|
case "tt", "bb", "tb", "bt":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func videoRangeLabel(s ffprobeStream) string {
|
|
if dv := dolbyVisionProfile(s.SideDataList); dv != "" {
|
|
return "DolbyVision"
|
|
}
|
|
if isHDR(s.ColorTransfer) {
|
|
return "HDR"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func dolbyVisionProfile(sideData []ffprobeSideData) string {
|
|
if profile := dolbyVisionProfileNumber(sideData); profile > 0 {
|
|
return fmt.Sprintf("Profile %d", profile)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func dolbyVisionProfileNumber(sideData []ffprobeSideData) int {
|
|
for _, data := range sideData {
|
|
if strings.EqualFold(data.SideDataType, "DOVI configuration record") && data.DVProfile > 0 {
|
|
return data.DVProfile
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func dolbyVisionBLCompatID(sideData []ffprobeSideData) int {
|
|
for _, data := range sideData {
|
|
if strings.EqualFold(data.SideDataType, "DOVI configuration record") && data.DVBLCompatID > 0 {
|
|
return data.DVBLCompatID
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func dolbyVisionELPresent(sideData []ffprobeSideData) bool {
|
|
for _, data := range sideData {
|
|
if strings.EqualFold(data.SideDataType, "DOVI configuration record") {
|
|
return data.DVElPresent > 0
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// dolbyVisionEnhancementLayer remains conservative until a libdovi-backed
|
|
// analyzer has inspected the RPU mapping. ffprobe can prove that an enhancement
|
|
// layer exists, but it cannot distinguish MEL from FEL.
|
|
func dolbyVisionEnhancementLayer(present bool) string {
|
|
if !present {
|
|
return "none"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func hasHDR10Plus(sideData []ffprobeSideData) bool {
|
|
for _, data := range sideData {
|
|
typ := strings.ToLower(data.SideDataType)
|
|
if strings.Contains(typ, "hdr10+") || strings.Contains(typ, "smpte2094-40") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func videoRangeType(s ffprobeStream) string {
|
|
profile := dolbyVisionProfileNumber(s.SideDataList)
|
|
hdr10Plus := hasHDR10Plus(s.SideDataList)
|
|
if profile > 0 {
|
|
switch profile {
|
|
case 5:
|
|
return "DOVI"
|
|
case 7:
|
|
if hdr10Plus {
|
|
return "DOVIWithELHDR10Plus"
|
|
}
|
|
return "DOVIWithEL"
|
|
case 8:
|
|
if hdr10Plus {
|
|
return "DOVIWithHDR10Plus"
|
|
}
|
|
switch dolbyVisionBLCompatID(s.SideDataList) {
|
|
case 1:
|
|
return "DOVIWithHDR10"
|
|
case 2:
|
|
return "DOVIWithSDR"
|
|
case 4:
|
|
return "DOVIWithHLG"
|
|
default:
|
|
if isHLG(s.ColorTransfer) {
|
|
return "DOVIWithHLG"
|
|
}
|
|
if isHDR(s.ColorTransfer) {
|
|
return "DOVIWithHDR10"
|
|
}
|
|
return "DOVIWithSDR"
|
|
}
|
|
default:
|
|
return "DOVI"
|
|
}
|
|
}
|
|
if hdr10Plus {
|
|
return "HDR10Plus"
|
|
}
|
|
if isHLG(s.ColorTransfer) {
|
|
return "HLG"
|
|
}
|
|
if isHDR(s.ColorTransfer) {
|
|
return "HDR10"
|
|
}
|
|
return "SDR"
|
|
}
|
|
|
|
func subtitleResolutionLabel(s ffprobeStream) string {
|
|
if s.Width <= 0 || s.Height <= 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%dx%d", s.Width, s.Height)
|
|
}
|
|
|
|
func dispositionFlag(tags map[string]string, key string) bool {
|
|
if tags == nil {
|
|
return false
|
|
}
|
|
value := strings.TrimSpace(strings.ToLower(tags[key]))
|
|
return value == "1" || value == "true" || value == "yes"
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// mapResolution converts video dimensions to a standard resolution string.
|
|
// Uses upper-bound bucketing (similar to Jellyfin) checking both width and
|
|
// height, which correctly handles ultra-wide and non-standard aspect ratios.
|
|
func mapResolution(width, height int) string {
|
|
switch {
|
|
case width <= 0 && height <= 0:
|
|
return ""
|
|
case width <= 854 && height <= 480:
|
|
return "480p"
|
|
case width <= 1280 && height <= 962:
|
|
return "720p"
|
|
case width <= 2560 && height <= 1440:
|
|
return "1080p"
|
|
case width <= 4096 && height <= 3072:
|
|
return "2160p"
|
|
case width <= 8192 && height <= 6144:
|
|
return "4320p"
|
|
default:
|
|
return "2160p"
|
|
}
|
|
}
|
|
|
|
// isHDR checks whether the color transfer characteristic indicates HDR content.
|
|
func isHDR(colorTransfer string) bool {
|
|
ct := strings.ToLower(colorTransfer)
|
|
return strings.Contains(ct, "smpte2084") || strings.Contains(ct, "arib-std-b67")
|
|
}
|
|
|
|
func isHLG(colorTransfer string) bool {
|
|
return strings.Contains(strings.ToLower(colorTransfer), "arib-std-b67")
|
|
}
|
|
|
|
// normalizeFormatTags lowercases tag keys so callers can look up
|
|
// "title", "artist", "album" without worrying about ffprobe's mixed-case
|
|
// output. Trims whitespace from values.
|
|
func normalizeFormatTags(raw map[string]string) map[string]string {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(raw))
|
|
for k, v := range raw {
|
|
out[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// detectContainer maps ffprobe format names to common container names.
|
|
func detectContainer(formatName string) string {
|
|
// ffprobe format_name can contain multiple names separated by commas
|
|
// e.g. "mov,mp4,m4a,3gp,3g2,mj2"
|
|
parts := strings.Split(formatName, ",")
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
switch p {
|
|
case "matroska", "webm":
|
|
return "mkv"
|
|
case "mov", "mp4", "m4a":
|
|
return "mp4"
|
|
case "avi":
|
|
return "avi"
|
|
case "mpegts":
|
|
return "ts"
|
|
case "flv":
|
|
return "flv"
|
|
case "ogg":
|
|
return "ogg"
|
|
case "wmv", "asf":
|
|
return "wmv"
|
|
}
|
|
}
|
|
// Fallback: return first part
|
|
if len(parts) > 0 && parts[0] != "" {
|
|
return strings.TrimSpace(parts[0])
|
|
}
|
|
return formatName
|
|
}
|