-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: CalcMinVersion methods for both Master and Media playlists
Implements all Compatibility Version rules except the ones for SKIP. The sample playlists have been updated to follow the new rules.
- Loading branch information
Showing
16 changed files
with
526 additions
and
47 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,252 @@ | ||
package m3u8 | ||
|
||
import "strings" | ||
|
||
func updateMin(ver *uint8, reason *string, newVer uint8, newReason string) { | ||
if newVer <= *ver { // only update if higher version | ||
return | ||
} | ||
*ver = newVer | ||
*reason = newReason | ||
} | ||
|
||
// CalcMinVersion returns the minimal version of the HLS protocol that is | ||
// required to support the playlist according to the [HLS Prococcol Version Compatibility]. | ||
// The reason is a human-readable string explaining why the version is required. | ||
func (p *MasterPlaylist) CalcMinVersion() (ver uint8, reason string) { | ||
ver = minVer | ||
reason = "minimal version supported by this library" | ||
|
||
// A Multivariant Playlist MUST indicate an EXT-X-VERSION of 7 or higher | ||
// if it contains: | ||
// * "SERVICE" values for the INSTREAM-ID attribute of the EXT-X-MEDIA | ||
for _, variant := range p.Variants { | ||
for _, alt := range variant.Alternatives { | ||
if strings.HasPrefix(alt.InstreamId, "SERVICE") { | ||
updateMin(&ver, &reason, 7, "SERVICE value for the INSTREAM-ID attribute of the EXT-X-MEDIA") | ||
break | ||
} | ||
} | ||
} | ||
// A Playlist MUST indicate an EXT-X-VERSION of 11 or higher if it contains: | ||
// * An EXT-X-DEFINE tag with a QUERYPARAM attribute. | ||
for _, define := range p.Defines { | ||
if define.Type == QUERYPARAM { | ||
updateMin(&ver, &reason, 11, "EXT-X-DEFINE tag with a QUERYPARAM attribute") | ||
} | ||
} | ||
|
||
// A Playlist MUST indicate an EXT-X-VERSION of 12 or higher if it contains: | ||
// * An attribute whose name starts with "REQ-". | ||
// This is only defined for EXT-X-STREAM-INF and EXT-X-I-FRAME-STREAM-INF tags | ||
// in the current version of the protocol. | ||
for _, variant := range p.Variants { | ||
if variant.ReqVideoLayout != "" { | ||
updateMin(&ver, &reason, 12, "REQ- attribute") | ||
} | ||
} | ||
|
||
return ver, reason | ||
} | ||
|
||
// CalcMinVersion returns the minimal version of the HLS protocol that is | ||
// required to support the playlist according to the [HLS Prococcol Version Compatibility]. | ||
// The reason is a human-readable string explaining why the version is required. | ||
func (p *MediaPlaylist) CalcMinVersion() (ver uint8, reason string) { | ||
ver = minVer | ||
reason = "minimal version supported by this library" | ||
|
||
// A Media Playlist MUST indicate an EXT-X-VERSION of 4 or higher if it contains: | ||
// * The EXT-X-BYTERANGE tag. | ||
// * The EXT-X-I-FRAMES-ONLY tag. | ||
|
||
head := p.head | ||
count := p.count | ||
for i := uint(0); (i < p.winsize || p.winsize == 0) && count > 0; count-- { | ||
seg := p.Segments[head] | ||
head = (head + 1) % p.capacity | ||
if seg == nil { // protection from badly filled chunklists | ||
continue | ||
} | ||
if p.winsize > 0 { // skip for VOD playlists, where winsize = 0 | ||
i++ | ||
} | ||
if seg.Limit > 0 { | ||
updateMin(&ver, &reason, 4, "EXT-X-BYTERANGE tag") | ||
break | ||
} | ||
} | ||
|
||
if p.Iframe { | ||
updateMin(&ver, &reason, 4, "EXT-X-I-FRAMES-ONLY tag") | ||
} | ||
if p.Key != nil { | ||
if p.Key.Method == "SAMPLE-AES" || p.Key.Keyformat != "" || p.Key.Keyformatversions != "" { | ||
updateMin(&ver, &reason, 5, | ||
"EXT-X-KEY tag with a METHOD of SAMPLE-AES, KEYFORMAT or KEYFORMATVERSIONS attributes") | ||
} | ||
} | ||
if p.Map != nil { | ||
updateMin(&ver, &reason, 5, "EXT-X-MAP tag") | ||
} | ||
|
||
head = p.head | ||
count = p.count | ||
for i := uint(0); (i < p.winsize || p.winsize == 0) && count > 0; count-- { | ||
seg := p.Segments[head] | ||
head = (head + 1) % p.capacity | ||
if seg == nil { // protection from badly filled chunklists | ||
continue | ||
} | ||
if p.winsize > 0 { // skip for VOD playlists, where winsize = 0 | ||
i++ | ||
} | ||
if seg.Key != nil { | ||
if seg.Key.Method == "SAMPLE-AES" || seg.Key.Keyformat != "" || | ||
seg.Key.Keyformatversions != "" { | ||
updateMin(&ver, &reason, 5, | ||
"EXT-X-KEY tag with a METHOD of SAMPLE-AES, KEYFORMAT or KEYFORMATVERSIONS attributes") | ||
} | ||
} | ||
if seg.Map != nil { | ||
updateMin(&ver, &reason, 5, "EXT-X-MAP tag") | ||
if !p.Iframe { | ||
updateMin(&ver, &reason, 6, | ||
"EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY") | ||
} | ||
} | ||
} | ||
|
||
if p.Map != nil && !p.Iframe { | ||
updateMin(&ver, &reason, 6, | ||
"EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY") | ||
} | ||
|
||
if len(p.Defines) > 0 { | ||
updateMin(&ver, &reason, 8, "Variable substitution") | ||
} | ||
|
||
// EXT-X-SKIP tag triggers version 10. Not implemented yet. | ||
// Also a bit unclear how to check for it since it may be generated in a request | ||
/* A Playlist MUST indicate an EXT-X-VERSION of 9 or higher if it | ||
contains: | ||
* The EXT-X-SKIP tag. | ||
A Playlist MUST indicate an EXT-X-VERSION of 10 or higher if it | ||
contains: | ||
* An EXT-X-SKIP tag that replaces EXT-X-DATERANGE tags in a Playlist | ||
Delta Update. | ||
*/ | ||
|
||
for _, def := range p.Defines { | ||
if def.Type == QUERYPARAM { | ||
updateMin(&ver, &reason, 11, | ||
"EXT-X-DEFINE tag with a QUERYPARAM attribute") | ||
} | ||
} | ||
|
||
return ver, reason | ||
} | ||
|
||
// [HLS Prococcol Version Compatibility]: https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-16#section-8 | ||
|
||
/* | ||
From https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-16 | ||
This library only supports level 3 and higher, so we don't check | ||
for level 1 and 2 compatibility. | ||
8. Protocol Version Compatibility | ||
Protocol compatibility is specified by the EXT-X-VERSION tag. A | ||
Playlist that contains tags or attributes that are not compatible | ||
with protocol version 1 MUST include an EXT-X-VERSION tag. | ||
A client MUST NOT attempt playback if it does not support the | ||
protocol version specified by the EXT-X-VERSION tag, or unintended | ||
behavior could occur. | ||
A Media Playlist MUST indicate an EXT-X-VERSION of 2 or higher if it | ||
contains: | ||
* The IV attribute of the EXT-X-KEY tag. | ||
A Media Playlist MUST indicate an EXT-X-VERSION of 3 or higher if it | ||
contains: | ||
* Floating-point EXTINF duration values. | ||
A Media Playlist MUST indicate an EXT-X-VERSION of 4 or higher if it | ||
contains: | ||
* The EXT-X-BYTERANGE tag. | ||
* The EXT-X-I-FRAMES-ONLY tag. | ||
A Media Playlist MUST indicate an EXT-X-VERSION of 5 or higher if it | ||
contains: | ||
* An EXT-X-KEY tag with a METHOD of SAMPLE-AES. | ||
* The KEYFORMAT and KEYFORMATVERSIONS attributes of the EXT-X-KEY | ||
tag. | ||
* The EXT-X-MAP tag. | ||
A Media Playlist MUST indicate an EXT-X-VERSION of 6 or higher if it | ||
contains: | ||
* The EXT-X-MAP tag in a Media Playlist that does not contain EXT- | ||
X-I-FRAMES-ONLY. | ||
Note that in protocol version 6, the semantics of the EXT- | ||
X-TARGETDURATION tag changed slightly. In protocol version 5 and | ||
earlier it indicated the maximum segment duration; in protocol | ||
version 6 and later it indicates the maximum segment duration rounded | ||
to the nearest integer number of seconds. | ||
A Multivariant Playlist MUST indicate an EXT-X-VERSION of 7 or higher | ||
if it contains: | ||
* "SERVICE" values for the INSTREAM-ID attribute of the EXT-X-MEDIA | ||
tag. | ||
A Playlist MUST indicate an EXT-X-VERSION of 8 or higher if it | ||
contains: | ||
* Variable substitution. | ||
A Playlist MUST indicate an EXT-X-VERSION of 9 or higher if it | ||
contains: | ||
* The EXT-X-SKIP tag. | ||
A Playlist MUST indicate an EXT-X-VERSION of 10 or higher if it | ||
contains: | ||
* An EXT-X-SKIP tag that replaces EXT-X-DATERANGE tags in a Playlist | ||
Delta Update. | ||
A Playlist MUST indicate an EXT-X-VERSION of 11 or higher if it | ||
contains: | ||
* An EXT-X-DEFINE tag with a QUERYPARAM attribute. | ||
A Playlist MUST indicate an EXT-X-VERSION of 12 or higher if it | ||
contains: | ||
* An attribute whose name starts with "REQ-". | ||
The EXT-X-MEDIA tag and the AUDIO, VIDEO, and SUBTITLES attributes of | ||
the EXT-X-STREAM-INF tag are backward compatible to protocol version | ||
1, but playback on older clients may not be desirable. A server MAY | ||
consider indicating an EXT-X-VERSION of 4 or higher in the | ||
Multivariant Playlist but is not required to do so. | ||
The PROGRAM-ID attribute of the EXT-X-STREAM-INF and the EXT-X-I- | ||
FRAME-STREAM-INF tags was removed in protocol version 6. | ||
The EXT-X-ALLOW-CACHE tag was removed in protocol version 7. | ||
*/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
package m3u8 | ||
|
||
import ( | ||
"bufio" | ||
"fmt" | ||
"os" | ||
"strings" | ||
"testing" | ||
|
||
"github.com/matryer/is" | ||
) | ||
|
||
func TestCalcMinVersionMasterPlaylist(t *testing.T) { | ||
is := is.New(t) | ||
pl3 := NewMasterPlaylist() | ||
|
||
pl7 := NewMasterPlaylist() | ||
pl7.Variants = append(pl7.Variants, &Variant{ | ||
VariantParams: VariantParams{ | ||
Alternatives: []*Alternative{{InstreamId: "SERVICE1"}}, | ||
}, | ||
}) | ||
|
||
pl11, err := readTestMasterPlaylist(t, "sample-playlists/master-with-defines.m3u8") | ||
is.NoErr(err) // must decode sample-playlists/master-with-defines.m3u8 | ||
|
||
pl12, err := readTestMasterPlaylist(t, "sample-playlists/master-with-req-video-layout.m3u8") | ||
is.NoErr(err) // must decode sample-playlists/master-with-req-video-layout.m3u8 | ||
|
||
cases := []struct { | ||
playlist Playlist | ||
expectedVersion uint8 | ||
expectedReason string | ||
}{ | ||
{pl3, minVer, "minimal version supported by this library"}, | ||
{pl7, 7, "SERVICE value for the INSTREAM-ID attribute of the EXT-X-MEDIA"}, | ||
{pl11, 11, "EXT-X-DEFINE tag with a QUERYPARAM attribute"}, | ||
{pl12, 12, "REQ- attribute"}, | ||
} | ||
|
||
for i, c := range cases { | ||
t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) { | ||
is := is.New(t) | ||
ver, reason := c.playlist.CalcMinVersion() | ||
is.Equal(ver, c.expectedVersion) | ||
is.Equal(reason, c.expectedReason) | ||
}) | ||
} | ||
} | ||
|
||
func TestCalcMinVersionMediaPlaylist(t *testing.T) { | ||
|
||
is := is.New(t) | ||
|
||
pl3, err := NewMediaPlaylist(10, 10) | ||
is.NoErr(err) // must create media playlist | ||
|
||
pl4ByteRange, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-with-byterange.m3u8") | ||
is.NoErr(err) // must decode sample-playlists/media-playlist-with-byterange.m3u8 | ||
|
||
pl4IframesOnly, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-with-iframes-only.m3u8") | ||
is.NoErr(err) // must decode sample-playlists/media-playlist-with-iframes-only.m3u8 | ||
|
||
pl5IframesOnlyAndMap, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-with-iframes-only-and-map.m3u8") | ||
is.NoErr(err) // must decode sample-playlists/media-playlist-with-iframes-only-and-map.m3u8 | ||
|
||
pl5SampleAES, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-with-key.m3u8") | ||
is.NoErr(err) // must decode sample-playlists/media-playlist-with-key.m3u8 | ||
|
||
pl6Fmp4, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-fmp4.m3u8") | ||
is.NoErr(err) // must decode sample-playlists/media-playlist-fmp4.m3u8 | ||
|
||
pl8VariableSubstitution, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-with-defines.m3u8") | ||
is.NoErr(err) // must decode sample-playlists/media-playlist-with-defines.m3u8 | ||
|
||
pl11QueryParam, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-with-queryparam.m3u8") | ||
is.NoErr(err) // must decode sample-playlists/media-playlist-with-queryparam.m3u8 | ||
|
||
cases := []struct { | ||
playlist Playlist | ||
expectedVersion uint8 | ||
expectedReason string | ||
}{ | ||
{pl3, minVer, "minimal version supported by this library"}, | ||
{pl4ByteRange, 4, "EXT-X-BYTERANGE tag"}, | ||
{pl4IframesOnly, 4, "EXT-X-I-FRAMES-ONLY tag"}, | ||
{pl5IframesOnlyAndMap, 5, "EXT-X-MAP tag"}, | ||
{pl5SampleAES, 5, "EXT-X-KEY tag with a METHOD of SAMPLE-AES, KEYFORMAT or KEYFORMATVERSIONS attributes"}, | ||
{pl6Fmp4, 6, "EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY"}, | ||
{pl8VariableSubstitution, 8, "Variable substitution"}, | ||
{pl11QueryParam, 11, "EXT-X-DEFINE tag with a QUERYPARAM attribute"}, | ||
} | ||
|
||
for i, c := range cases { | ||
t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) { | ||
is := is.New(t) | ||
ver, reason := c.playlist.CalcMinVersion() | ||
is.Equal(ver, c.expectedVersion) | ||
is.Equal(reason, c.expectedReason) | ||
}) | ||
} | ||
} | ||
|
||
func readTestPlaylist(t *testing.T, fileName string) Playlist { | ||
t.Helper() | ||
f, err := os.Open(fileName) | ||
if err != nil { | ||
t.Fail() | ||
} | ||
defer f.Close() | ||
|
||
p, _, err := DecodeFrom(bufio.NewReader(f), false) | ||
if err != nil { | ||
t.Fail() | ||
} | ||
return p | ||
} | ||
|
||
func TestAllPlaylistVersions(t *testing.T) { | ||
is := is.New(t) | ||
|
||
// Read all m3u8 files in sample-playlists directory | ||
files, err := os.ReadDir("sample-playlists") | ||
is.NoErr(err) | ||
|
||
for _, file := range files { | ||
fName := file.Name() | ||
if !strings.HasSuffix(fName, ".m3u8") { | ||
continue | ||
} | ||
|
||
t.Run(fName, func(t *testing.T) { | ||
path := "sample-playlists/" + fName | ||
|
||
p := readTestPlaylist(t, path) | ||
|
||
minVer, reason := p.CalcMinVersion() | ||
actualVer := p.Version() | ||
if minVer > actualVer { | ||
t.Errorf("Playlist %s: CalcMinVersion=%d but Version=%d (reason: %s)", | ||
fName, minVer, actualVer, reason) | ||
} | ||
}) | ||
} | ||
} |
Oops, something went wrong.