diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES index f78ec6b..29225ab 100644 --- a/THIRD_PARTY_NOTICES +++ b/THIRD_PARTY_NOTICES @@ -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. diff --git a/go.mod b/go.mod index 4068889..5d30ca7 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 75a13bc..ebbdccd 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/mpd/duration.go b/mpd/duration.go index 8ebc870..be973c7 100644 --- a/mpd/duration.go +++ b/mpd/duration.go @@ -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 } diff --git a/mpd/duration_test.go b/mpd/duration_test.go index edd29b6..a7c931d 100644 --- a/mpd/duration_test.go +++ b/mpd/duration_test.go @@ -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()) + }) } } @@ -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)) + }) } } diff --git a/mpd/fixtures/newperiod.mpd b/mpd/fixtures/newperiod.mpd index 6f6482d..8a934b1 100644 --- a/mpd/fixtures/newperiod.mpd +++ b/mpd/fixtures/newperiod.mpd @@ -8,7 +8,7 @@ - +