Skip to content

Commit f656b0e

Browse files
authored
Parse durations of less than a second (#109)
* feat: Parse durations of less than a second. * chore: add test for less than second parsing * chore: rename func * chore: fix IDE warns * chore: to lower start of error messages * feat: Add chrono library * feat: Replace manual duration parsing with chrono parsing * chore: correct failing tests
1 parent dee6c44 commit f656b0e

File tree

6 files changed

+72
-189
lines changed

6 files changed

+72
-189
lines changed

THIRD_PARTY_NOTICES

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
1-
### Third-Party Libraries
1+
## Third-Party Notices
22

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

99
This library is used in compliance with the Apache License, Version 2.0.
10+
11+
### chrono
12+
- Copyright (c) 2020 Joe Mann
13+
- Licensed under the MIT License
14+
- Source: https://github.com/go-chrono/chrono/tree/master
15+
16+
Permission is hereby granted, free of charge, to any person obtaining a copy
17+
of this software and associated documentation files (the "Software"), to deal
18+
in the Software without restriction, including without limitation the rights
19+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
20+
copies of the Software, and to permit persons to whom the Software is
21+
furnished to do so, subject to the following conditions:
22+
23+
The above copyright notice and this permission notice shall be included in all
24+
copies or substantial portions of the Software.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ require github.com/Comcast/scte35-go v1.4.6
88

99
require (
1010
github.com/bamiaux/iobit v0.0.0-20170418073505-498159a04883 // indirect
11+
github.com/go-chrono/chrono v0.0.0-20250124203826-0422557264a6 // indirect
1112
golang.org/x/text v0.16.0 // indirect
1213
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ github.com/bamiaux/iobit v0.0.0-20170418073505-498159a04883 h1:XNtOMwxmV2PI/vuTH
44
github.com/bamiaux/iobit v0.0.0-20170418073505-498159a04883/go.mod h1:9IjZnSQGh45J46HHS45pxuMJ6WFTtSXbaX0FoHDvxh8=
55
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
66
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7+
github.com/go-chrono/chrono v0.0.0-20250124203826-0422557264a6 h1:bZajBUDqyayXRqKAD/wX8AVPOeuFvwLAwTZFCvWnohs=
8+
github.com/go-chrono/chrono v0.0.0-20250124203826-0422557264a6/go.mod h1:uTWQdzrjtft2vWY+f+KQ9e3DXHsP0SzhE5SLIicFo08=
79
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
810
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
911
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=

mpd/duration.go

Lines changed: 26 additions & 169 deletions
Original file line numberDiff line numberDiff line change
@@ -5,202 +5,59 @@ package mpd
55
import (
66
"encoding/xml"
77
"errors"
8-
"fmt"
9-
"regexp"
10-
"strconv"
11-
"strings"
128
"time"
9+
10+
"github.com/go-chrono/chrono"
1311
)
1412

1513
type Duration time.Duration
1614

17-
var (
18-
rStart = "^P" // Must start with a 'P'
19-
rDays = "(\\d+D)?" // We only allow Days for durations, not Months or Years
20-
rTime = "(?:T" // If there's any 'time' units then they must be preceded by a 'T'
21-
rHours = "(\\d+H)?" // Hours
22-
rMinutes = "(\\d+M)?" // Minutes
23-
rSeconds = "([\\d.]+S)?" // Seconds (Potentially decimal)
24-
rEnd = ")?$" // end of regex must close "T" capture group
25-
)
26-
27-
var xmlDurationRegex = regexp.MustCompile(rStart + rDays + rTime + rHours + rMinutes + rSeconds + rEnd)
15+
var unsupportedFormatErr = errors.New("duration must be in the format: P[nD][T[nH][nM][nS]]")
2816

29-
func (d Duration) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
17+
func (d *Duration) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
3018
return xml.Attr{Name: name, Value: d.String()}, nil
3119
}
3220

3321
func (d *Duration) UnmarshalXMLAttr(attr xml.Attr) error {
34-
dur, err := ParseDuration(attr.Value)
22+
duration, err := ParseDuration(attr.Value)
3523
if err != nil {
3624
return err
3725
}
38-
*d = Duration(dur)
26+
*d = Duration(duration)
3927
return nil
4028
}
4129

42-
// String renders a Duration in XML Duration Data Type format
30+
// String parses the duration into a string with the use of the chrono library.
4331
func (d *Duration) String() string {
44-
// Largest time is 2540400h10m10.000000000s
45-
var buf [32]byte
46-
w := len(buf)
47-
48-
u := uint64(*d)
49-
neg := *d < 0
50-
if neg {
51-
u = -u
52-
}
53-
54-
if u < uint64(time.Second) {
55-
// Special case: if duration is smaller than a second,
56-
// use smaller units, like 1.2ms
57-
var prec int
58-
w--
59-
buf[w] = 'S'
60-
w--
61-
if u == 0 {
62-
return "PT0S"
63-
}
64-
/*
65-
switch {
66-
case u < uint64(Millisecond):
67-
// print microseconds
68-
prec = 3
69-
// U+00B5 'µ' micro sign == 0xC2 0xB5
70-
w-- // Need room for two bytes.
71-
copy(buf[w:], "µ")
72-
default:
73-
// print milliseconds
74-
prec = 6
75-
buf[w] = 'm'
76-
}
77-
*/
78-
w, u = fmtFrac(buf[:w], u, prec)
79-
w = fmtInt(buf[:w], u)
80-
} else {
81-
w--
82-
buf[w] = 'S'
83-
84-
w, u = fmtFrac(buf[:w], u, 9)
85-
86-
// u is now integer seconds
87-
w = fmtInt(buf[:w], u%60)
88-
u /= 60
89-
90-
// u is now integer minutes
91-
if u > 0 {
92-
w--
93-
buf[w] = 'M'
94-
w = fmtInt(buf[:w], u%60)
95-
u /= 60
96-
97-
// u is now integer hours
98-
// Stop at hours because days can be different lengths.
99-
if u > 0 {
100-
w--
101-
buf[w] = 'H'
102-
w = fmtInt(buf[:w], u)
103-
}
104-
}
105-
}
106-
107-
if neg {
108-
w--
109-
buf[w] = '-'
110-
}
111-
112-
return "PT" + string(buf[w:])
113-
}
114-
115-
// fmtFrac formats the fraction of v/10**prec (e.g., ".12345") into the
116-
// tail of buf, omitting trailing zeros. it omits the decimal
117-
// point too when the fraction is 0. It returns the index where the
118-
// output bytes begin and the value v/10**prec.
119-
func fmtFrac(buf []byte, v uint64, prec int) (nw int, nv uint64) {
120-
// Omit trailing zeros up to and including decimal point.
121-
w := len(buf)
122-
print := false
123-
for i := 0; i < prec; i++ {
124-
digit := v % 10
125-
print = print || digit != 0
126-
if print {
127-
w--
128-
buf[w] = byte(digit) + '0'
129-
}
130-
v /= 10
32+
if d == nil {
33+
return "PT0S"
13134
}
132-
if print {
133-
w--
134-
buf[w] = '.'
135-
}
136-
return w, v
137-
}
13835

139-
// fmtInt formats v into the tail of buf.
140-
// It returns the index where the output begins.
141-
func fmtInt(buf []byte, v uint64) int {
142-
w := len(buf)
143-
if v == 0 {
144-
w--
145-
buf[w] = '0'
146-
} else {
147-
for v > 0 {
148-
w--
149-
buf[w] = byte(v%10) + '0'
150-
v /= 10
151-
}
152-
}
153-
return w
36+
return chrono.DurationOf(chrono.Extent(*d)).String()
15437
}
15538

39+
// ParseDuration converts the given string into a time.Duration with the use of
40+
// the chrono library. The function doesn't allow the use of negative durations,
41+
// decimal valued periods, or the use of the year, month, or week units as they
42+
// don't make sense.
15643
func ParseDuration(str string) (time.Duration, error) {
157-
if len(str) < 3 {
158-
return 0, errors.New("At least one number and designator are required")
159-
}
160-
161-
if strings.Contains(str, "-") {
162-
return 0, errors.New("Duration cannot be negative")
163-
}
164-
165-
// Check that only the parts we expect exist and that everything's in the correct order
166-
if !xmlDurationRegex.Match([]byte(str)) {
167-
return 0, errors.New("Duration must be in the format: P[nD][T[nH][nM][nS]]")
168-
}
169-
170-
var parts = xmlDurationRegex.FindStringSubmatch(str)
171-
var total time.Duration
172-
173-
if parts[1] != "" {
174-
days, err := strconv.Atoi(strings.TrimRight(parts[1], "D"))
175-
if err != nil {
176-
return 0, fmt.Errorf("Error parsing Days: %s", err)
177-
}
178-
total += time.Duration(days) * time.Hour * 24
44+
period, duration, err := chrono.ParseDuration(str)
45+
if err != nil {
46+
return 0, unsupportedFormatErr
17947
}
18048

181-
if parts[2] != "" {
182-
hours, err := strconv.Atoi(strings.TrimRight(parts[2], "H"))
183-
if err != nil {
184-
return 0, fmt.Errorf("Error parsing Hours: %s", err)
185-
}
186-
total += time.Duration(hours) * time.Hour
49+
hasDecimalDays := period.Days != float32(int64(period.Days))
50+
hasUnsupportedUnits := period.Years+period.Months+period.Years > 0
51+
if hasDecimalDays || hasUnsupportedUnits {
52+
return 0, unsupportedFormatErr
18753
}
18854

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

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

205-
return total, nil
62+
return time.Duration(totalDur.Nanoseconds()), nil
20663
}

mpd/duration_test.go

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,19 @@ import (
1010

1111
func TestDuration(t *testing.T) {
1212
in := map[string]string{
13+
"0.5ms": "PT0.0005S",
14+
"7ms": "PT0.007S",
1315
"0s": "PT0S",
1416
"6m16s": "PT6M16S",
1517
"1.97s": "PT1.97S",
1618
}
1719
for ins, ex := range in {
18-
timeDur, err := time.ParseDuration(ins)
19-
require.NoError(t, err)
20-
dur := Duration(timeDur)
21-
require.EqualString(t, ex, dur.String())
20+
t.Run(ins, func(t *testing.T) {
21+
timeDur, err := time.ParseDuration(ins)
22+
require.NoError(t, err)
23+
dur := Duration(timeDur)
24+
require.EqualString(t, ex, dur.String())
25+
})
2226
}
2327
}
2428

@@ -34,27 +38,31 @@ func TestParseDuration(t *testing.T) {
3438
"PT20M": (20 * time.Minute).Seconds(),
3539
"PT1M30.5S": (time.Minute + 30*time.Second + 500*time.Millisecond).Seconds(),
3640
"PT1004199059S": (1004199059 * time.Second).Seconds(),
41+
"PT2M1H": (time.Minute*2 + time.Hour).Seconds(),
3742
}
3843
for ins, ex := range in {
39-
act, err := ParseDuration(ins)
40-
require.NoError(t, err, ins)
41-
require.EqualFloat64(t, ex, act.Seconds(), ins)
44+
t.Run(ins, func(t *testing.T) {
45+
act, err := ParseDuration(ins)
46+
require.NoError(t, err, ins)
47+
require.EqualFloat64(t, ex, act.Seconds(), ins)
48+
})
4249
}
4350
}
4451

4552
func TestParseBadDurations(t *testing.T) {
4653
in := map[string]string{
47-
"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)
48-
"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)
49-
"P15.5D": `Duration must be in the format: P[nD][T[nH][nM][nS]]`, // Only seconds can be expressed as a decimal
50-
"P2H": `Duration must be in the format: P[nD][T[nH][nM][nS]]`, // "T" must be present to separate days and hours
51-
"2DT1H": `Duration must be in the format: P[nD][T[nH][nM][nS]]`, // "P" must always be present
52-
"PT2M1H": `Duration must be in the format: P[nD][T[nH][nM][nS]]`, // Hours must appear before Minutes
53-
"P": `At least one number and designator are required`, // At least one number and designator are required
54-
"-P20H": `Duration cannot be negative`, // Negative duration doesn't make sense
54+
"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)
55+
"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)
56+
"P15.5D": `duration must be in the format: P[nD][T[nH][nM][nS]]`, // Only seconds can be expressed as a decimal
57+
"P2H": `duration must be in the format: P[nD][T[nH][nM][nS]]`, // "T" must be present to separate days and hours
58+
"2DT1H": `duration must be in the format: P[nD][T[nH][nM][nS]]`, // "P" must always be present
59+
"P": `duration must be in the format: P[nD][T[nH][nM][nS]]`, // At least one number and designator are required
60+
"-PT20H": `duration cannot be negative`, // Negative duration doesn't make sense
5561
}
5662
for ins, msg := range in {
57-
_, err := ParseDuration(ins)
58-
require.EqualError(t, err, msg, fmt.Sprintf("Expected an error for: %s", ins))
63+
t.Run(ins, func(t *testing.T) {
64+
_, err := ParseDuration(ins)
65+
require.EqualError(t, err, msg, fmt.Sprintf("Expected an error for: %s", ins))
66+
})
5967
}
6068
}

mpd/fixtures/newperiod.mpd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<SegmentTemplate duration="1968" initialization="$RepresentationID$/audio-1.mp4" media="$RepresentationID$/audio-1/seg-$Number$.m4f" startNumber="0" timescale="1000"></SegmentTemplate>
99
</AdaptationSet>
1010
</Period>
11-
<Period duration="PT3M0S">
11+
<Period duration="PT3M">
1212
<AdaptationSet mimeType="video/mp4" startWithSAP="1" scanType="progressive" id="2" segmentAlignment="true">
1313
<SegmentTemplate duration="1968" initialization="$RepresentationID$/video-2.mp4" media="$RepresentationID$/video-2/seg-$Number$.m4f" startNumber="0" timescale="1000"></SegmentTemplate>
1414
</AdaptationSet>

0 commit comments

Comments
 (0)