Skip to content

Parse durations of less than a second #109

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions THIRD_PARTY_NOTICES
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
### Third-Party Libraries
## Third-Party Notices

#### Library: scte35-go
### scte35-go
- License: Apache License 2.0
- URL: https://github.com/Comcast/scte35-go/tree/main
- Copyright: None explicitly stated.
- Notes: This library does not include a copyright notice, but it is licensed under the Apache License 2.0.

This library is used in compliance with the Apache License, Version 2.0.

### chrono
- Copyright (c) 2020 Joe Mann
- Licensed under the MIT License
- Source: https://github.com/go-chrono/chrono/tree/master

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ require github.com/Comcast/scte35-go v1.4.6

require (
github.com/bamiaux/iobit v0.0.0-20170418073505-498159a04883 // indirect
github.com/go-chrono/chrono v0.0.0-20250124203826-0422557264a6 // indirect
golang.org/x/text v0.16.0 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ github.com/bamiaux/iobit v0.0.0-20170418073505-498159a04883 h1:XNtOMwxmV2PI/vuTH
github.com/bamiaux/iobit v0.0.0-20170418073505-498159a04883/go.mod h1:9IjZnSQGh45J46HHS45pxuMJ6WFTtSXbaX0FoHDvxh8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chrono/chrono v0.0.0-20250124203826-0422557264a6 h1:bZajBUDqyayXRqKAD/wX8AVPOeuFvwLAwTZFCvWnohs=
github.com/go-chrono/chrono v0.0.0-20250124203826-0422557264a6/go.mod h1:uTWQdzrjtft2vWY+f+KQ9e3DXHsP0SzhE5SLIicFo08=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
Expand Down
195 changes: 26 additions & 169 deletions mpd/duration.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,202 +5,59 @@ package mpd
import (
"encoding/xml"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"

"github.com/go-chrono/chrono"
)

type Duration time.Duration

var (
rStart = "^P" // Must start with a 'P'
rDays = "(\\d+D)?" // We only allow Days for durations, not Months or Years
rTime = "(?:T" // If there's any 'time' units then they must be preceded by a 'T'
rHours = "(\\d+H)?" // Hours
rMinutes = "(\\d+M)?" // Minutes
rSeconds = "([\\d.]+S)?" // Seconds (Potentially decimal)
rEnd = ")?$" // end of regex must close "T" capture group
)

var xmlDurationRegex = regexp.MustCompile(rStart + rDays + rTime + rHours + rMinutes + rSeconds + rEnd)
var unsupportedFormatErr = errors.New("duration must be in the format: P[nD][T[nH][nM][nS]]")

func (d Duration) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
func (d *Duration) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
return xml.Attr{Name: name, Value: d.String()}, nil
}

func (d *Duration) UnmarshalXMLAttr(attr xml.Attr) error {
dur, err := ParseDuration(attr.Value)
duration, err := ParseDuration(attr.Value)
if err != nil {
return err
}
*d = Duration(dur)
*d = Duration(duration)
return nil
}

// String renders a Duration in XML Duration Data Type format
// String parses the duration into a string with the use of the chrono library.
func (d *Duration) String() string {
// Largest time is 2540400h10m10.000000000s
var buf [32]byte
w := len(buf)

u := uint64(*d)
neg := *d < 0
if neg {
u = -u
}

if u < uint64(time.Second) {
// Special case: if duration is smaller than a second,
// use smaller units, like 1.2ms
var prec int
w--
buf[w] = 'S'
w--
if u == 0 {
return "PT0S"
}
/*
switch {
case u < uint64(Millisecond):
// print microseconds
prec = 3
// U+00B5 'µ' micro sign == 0xC2 0xB5
w-- // Need room for two bytes.
copy(buf[w:], "µ")
default:
// print milliseconds
prec = 6
buf[w] = 'm'
}
*/
w, u = fmtFrac(buf[:w], u, prec)
w = fmtInt(buf[:w], u)
} else {
w--
buf[w] = 'S'

w, u = fmtFrac(buf[:w], u, 9)

// u is now integer seconds
w = fmtInt(buf[:w], u%60)
u /= 60

// u is now integer minutes
if u > 0 {
w--
buf[w] = 'M'
w = fmtInt(buf[:w], u%60)
u /= 60

// u is now integer hours
// Stop at hours because days can be different lengths.
if u > 0 {
w--
buf[w] = 'H'
w = fmtInt(buf[:w], u)
}
}
}

if neg {
w--
buf[w] = '-'
}

return "PT" + string(buf[w:])
}

// fmtFrac formats the fraction of v/10**prec (e.g., ".12345") into the
// tail of buf, omitting trailing zeros. it omits the decimal
// point too when the fraction is 0. It returns the index where the
// output bytes begin and the value v/10**prec.
func fmtFrac(buf []byte, v uint64, prec int) (nw int, nv uint64) {
// Omit trailing zeros up to and including decimal point.
w := len(buf)
print := false
for i := 0; i < prec; i++ {
digit := v % 10
print = print || digit != 0
if print {
w--
buf[w] = byte(digit) + '0'
}
v /= 10
if d == nil {
return "PT0S"
}
if print {
w--
buf[w] = '.'
}
return w, v
}

// fmtInt formats v into the tail of buf.
// It returns the index where the output begins.
func fmtInt(buf []byte, v uint64) int {
w := len(buf)
if v == 0 {
w--
buf[w] = '0'
} else {
for v > 0 {
w--
buf[w] = byte(v%10) + '0'
v /= 10
}
}
return w
return chrono.DurationOf(chrono.Extent(*d)).String()
}

// ParseDuration converts the given string into a time.Duration with the use of
// the chrono library. The function doesn't allow the use of negative durations,
// decimal valued periods, or the use of the year, month, or week units as they
// don't make sense.
func ParseDuration(str string) (time.Duration, error) {
if len(str) < 3 {
return 0, errors.New("At least one number and designator are required")
}

if strings.Contains(str, "-") {
return 0, errors.New("Duration cannot be negative")
}

// Check that only the parts we expect exist and that everything's in the correct order
if !xmlDurationRegex.Match([]byte(str)) {
return 0, errors.New("Duration must be in the format: P[nD][T[nH][nM][nS]]")
}

var parts = xmlDurationRegex.FindStringSubmatch(str)
var total time.Duration

if parts[1] != "" {
days, err := strconv.Atoi(strings.TrimRight(parts[1], "D"))
if err != nil {
return 0, fmt.Errorf("Error parsing Days: %s", err)
}
total += time.Duration(days) * time.Hour * 24
period, duration, err := chrono.ParseDuration(str)
if err != nil {
return 0, unsupportedFormatErr
}

if parts[2] != "" {
hours, err := strconv.Atoi(strings.TrimRight(parts[2], "H"))
if err != nil {
return 0, fmt.Errorf("Error parsing Hours: %s", err)
}
total += time.Duration(hours) * time.Hour
hasDecimalDays := period.Days != float32(int64(period.Days))
hasUnsupportedUnits := period.Years+period.Months+period.Years > 0
if hasDecimalDays || hasUnsupportedUnits {
return 0, unsupportedFormatErr
}

if parts[3] != "" {
mins, err := strconv.Atoi(strings.TrimRight(parts[3], "M"))
if err != nil {
return 0, fmt.Errorf("Error parsing Minutes: %s", err)
}
total += time.Duration(mins) * time.Minute
}
durationDays := chrono.Extent(period.Days) * 24 * chrono.Hour
totalDur := duration.Add(chrono.DurationOf(durationDays))

if parts[4] != "" {
secs, err := strconv.ParseFloat(strings.TrimRight(parts[4], "S"), 64)
if err != nil {
return 0, fmt.Errorf("Error parsing Seconds: %s", err)
}
total += time.Duration(secs * float64(time.Second))
if totalDur.Compare(chrono.Duration{}) == -1 {
return 0, errors.New("duration cannot be negative")
}

return total, nil
return time.Duration(totalDur.Nanoseconds()), nil
}
42 changes: 25 additions & 17 deletions mpd/duration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,19 @@ import (

func TestDuration(t *testing.T) {
in := map[string]string{
"0.5ms": "PT0.0005S",
"7ms": "PT0.007S",
"0s": "PT0S",
"6m16s": "PT6M16S",
"1.97s": "PT1.97S",
}
for ins, ex := range in {
timeDur, err := time.ParseDuration(ins)
require.NoError(t, err)
dur := Duration(timeDur)
require.EqualString(t, ex, dur.String())
t.Run(ins, func(t *testing.T) {
timeDur, err := time.ParseDuration(ins)
require.NoError(t, err)
dur := Duration(timeDur)
require.EqualString(t, ex, dur.String())
})
}
}

Expand All @@ -34,27 +38,31 @@ func TestParseDuration(t *testing.T) {
"PT20M": (20 * time.Minute).Seconds(),
"PT1M30.5S": (time.Minute + 30*time.Second + 500*time.Millisecond).Seconds(),
"PT1004199059S": (1004199059 * time.Second).Seconds(),
"PT2M1H": (time.Minute*2 + time.Hour).Seconds(),
}
for ins, ex := range in {
act, err := ParseDuration(ins)
require.NoError(t, err, ins)
require.EqualFloat64(t, ex, act.Seconds(), ins)
t.Run(ins, func(t *testing.T) {
act, err := ParseDuration(ins)
require.NoError(t, err, ins)
require.EqualFloat64(t, ex, act.Seconds(), ins)
})
}
}

func TestParseBadDurations(t *testing.T) {
in := map[string]string{
"P20M": `Duration must be in the format: P[nD][T[nH][nM][nS]]`, // We don't allow Months (doesn't make sense when converting to duration)
"P20Y": `Duration must be in the format: P[nD][T[nH][nM][nS]]`, // We don't allow Years (doesn't make sense when converting to duration)
"P15.5D": `Duration must be in the format: P[nD][T[nH][nM][nS]]`, // Only seconds can be expressed as a decimal
"P2H": `Duration must be in the format: P[nD][T[nH][nM][nS]]`, // "T" must be present to separate days and hours
"2DT1H": `Duration must be in the format: P[nD][T[nH][nM][nS]]`, // "P" must always be present
"PT2M1H": `Duration must be in the format: P[nD][T[nH][nM][nS]]`, // Hours must appear before Minutes
"P": `At least one number and designator are required`, // At least one number and designator are required
"-P20H": `Duration cannot be negative`, // Negative duration doesn't make sense
"P20M": `duration must be in the format: P[nD][T[nH][nM][nS]]`, // We don't allow Months (doesn't make sense when converting to duration)
"P20Y": `duration must be in the format: P[nD][T[nH][nM][nS]]`, // We don't allow Years (doesn't make sense when converting to duration)
"P15.5D": `duration must be in the format: P[nD][T[nH][nM][nS]]`, // Only seconds can be expressed as a decimal
"P2H": `duration must be in the format: P[nD][T[nH][nM][nS]]`, // "T" must be present to separate days and hours
"2DT1H": `duration must be in the format: P[nD][T[nH][nM][nS]]`, // "P" must always be present
"P": `duration must be in the format: P[nD][T[nH][nM][nS]]`, // At least one number and designator are required
"-PT20H": `duration cannot be negative`, // Negative duration doesn't make sense
}
for ins, msg := range in {
_, err := ParseDuration(ins)
require.EqualError(t, err, msg, fmt.Sprintf("Expected an error for: %s", ins))
t.Run(ins, func(t *testing.T) {
_, err := ParseDuration(ins)
require.EqualError(t, err, msg, fmt.Sprintf("Expected an error for: %s", ins))
})
}
}
2 changes: 1 addition & 1 deletion mpd/fixtures/newperiod.mpd
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<SegmentTemplate duration="1968" initialization="$RepresentationID$/audio-1.mp4" media="$RepresentationID$/audio-1/seg-$Number$.m4f" startNumber="0" timescale="1000"></SegmentTemplate>
</AdaptationSet>
</Period>
<Period duration="PT3M0S">
<Period duration="PT3M">
<AdaptationSet mimeType="video/mp4" startWithSAP="1" scanType="progressive" id="2" segmentAlignment="true">
<SegmentTemplate duration="1968" initialization="$RepresentationID$/video-2.mp4" media="$RepresentationID$/video-2/seg-$Number$.m4f" startNumber="0" timescale="1000"></SegmentTemplate>
</AdaptationSet>
Expand Down