Skip to content

Commit 4ebec73

Browse files
committed
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.
1 parent 6f2d765 commit 4ebec73

16 files changed

+526
-47
lines changed

m3u8/calcversion.go

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
package m3u8
2+
3+
import "strings"
4+
5+
func updateMin(ver *uint8, reason *string, newVer uint8, newReason string) {
6+
if newVer <= *ver { // only update if higher version
7+
return
8+
}
9+
*ver = newVer
10+
*reason = newReason
11+
}
12+
13+
// CalcMinVersion returns the minimal version of the HLS protocol that is
14+
// required to support the playlist according to the [HLS Prococcol Version Compatibility].
15+
// The reason is a human-readable string explaining why the version is required.
16+
func (p *MasterPlaylist) CalcMinVersion() (ver uint8, reason string) {
17+
ver = minVer
18+
reason = "minimal version supported by this library"
19+
20+
// A Multivariant Playlist MUST indicate an EXT-X-VERSION of 7 or higher
21+
// if it contains:
22+
// * "SERVICE" values for the INSTREAM-ID attribute of the EXT-X-MEDIA
23+
for _, variant := range p.Variants {
24+
for _, alt := range variant.Alternatives {
25+
if strings.HasPrefix(alt.InstreamId, "SERVICE") {
26+
updateMin(&ver, &reason, 7, "SERVICE value for the INSTREAM-ID attribute of the EXT-X-MEDIA")
27+
break
28+
}
29+
}
30+
}
31+
// A Playlist MUST indicate an EXT-X-VERSION of 11 or higher if it contains:
32+
// * An EXT-X-DEFINE tag with a QUERYPARAM attribute.
33+
for _, define := range p.Defines {
34+
if define.Type == QUERYPARAM {
35+
updateMin(&ver, &reason, 11, "EXT-X-DEFINE tag with a QUERYPARAM attribute")
36+
}
37+
}
38+
39+
// A Playlist MUST indicate an EXT-X-VERSION of 12 or higher if it contains:
40+
// * An attribute whose name starts with "REQ-".
41+
// This is only defined for EXT-X-STREAM-INF and EXT-X-I-FRAME-STREAM-INF tags
42+
// in the current version of the protocol.
43+
for _, variant := range p.Variants {
44+
if variant.ReqVideoLayout != "" {
45+
updateMin(&ver, &reason, 12, "REQ- attribute")
46+
}
47+
}
48+
49+
return ver, reason
50+
}
51+
52+
// CalcMinVersion returns the minimal version of the HLS protocol that is
53+
// required to support the playlist according to the [HLS Prococcol Version Compatibility].
54+
// The reason is a human-readable string explaining why the version is required.
55+
func (p *MediaPlaylist) CalcMinVersion() (ver uint8, reason string) {
56+
ver = minVer
57+
reason = "minimal version supported by this library"
58+
59+
// A Media Playlist MUST indicate an EXT-X-VERSION of 4 or higher if it contains:
60+
// * The EXT-X-BYTERANGE tag.
61+
// * The EXT-X-I-FRAMES-ONLY tag.
62+
63+
head := p.head
64+
count := p.count
65+
for i := uint(0); (i < p.winsize || p.winsize == 0) && count > 0; count-- {
66+
seg := p.Segments[head]
67+
head = (head + 1) % p.capacity
68+
if seg == nil { // protection from badly filled chunklists
69+
continue
70+
}
71+
if p.winsize > 0 { // skip for VOD playlists, where winsize = 0
72+
i++
73+
}
74+
if seg.Limit > 0 {
75+
updateMin(&ver, &reason, 4, "EXT-X-BYTERANGE tag")
76+
break
77+
}
78+
}
79+
80+
if p.Iframe {
81+
updateMin(&ver, &reason, 4, "EXT-X-I-FRAMES-ONLY tag")
82+
}
83+
if p.Key != nil {
84+
if p.Key.Method == "SAMPLE-AES" || p.Key.Keyformat != "" || p.Key.Keyformatversions != "" {
85+
updateMin(&ver, &reason, 5,
86+
"EXT-X-KEY tag with a METHOD of SAMPLE-AES, KEYFORMAT or KEYFORMATVERSIONS attributes")
87+
}
88+
}
89+
if p.Map != nil {
90+
updateMin(&ver, &reason, 5, "EXT-X-MAP tag")
91+
}
92+
93+
head = p.head
94+
count = p.count
95+
for i := uint(0); (i < p.winsize || p.winsize == 0) && count > 0; count-- {
96+
seg := p.Segments[head]
97+
head = (head + 1) % p.capacity
98+
if seg == nil { // protection from badly filled chunklists
99+
continue
100+
}
101+
if p.winsize > 0 { // skip for VOD playlists, where winsize = 0
102+
i++
103+
}
104+
if seg.Key != nil {
105+
if seg.Key.Method == "SAMPLE-AES" || seg.Key.Keyformat != "" ||
106+
seg.Key.Keyformatversions != "" {
107+
updateMin(&ver, &reason, 5,
108+
"EXT-X-KEY tag with a METHOD of SAMPLE-AES, KEYFORMAT or KEYFORMATVERSIONS attributes")
109+
}
110+
}
111+
if seg.Map != nil {
112+
updateMin(&ver, &reason, 5, "EXT-X-MAP tag")
113+
if !p.Iframe {
114+
updateMin(&ver, &reason, 6,
115+
"EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY")
116+
}
117+
}
118+
}
119+
120+
if p.Map != nil && !p.Iframe {
121+
updateMin(&ver, &reason, 6,
122+
"EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY")
123+
}
124+
125+
if len(p.Defines) > 0 {
126+
updateMin(&ver, &reason, 8, "Variable substitution")
127+
}
128+
129+
// EXT-X-SKIP tag triggers version 10. Not implemented yet.
130+
// Also a bit unclear how to check for it since it may be generated in a request
131+
/* A Playlist MUST indicate an EXT-X-VERSION of 9 or higher if it
132+
contains:
133+
134+
* The EXT-X-SKIP tag.
135+
136+
A Playlist MUST indicate an EXT-X-VERSION of 10 or higher if it
137+
contains:
138+
139+
* An EXT-X-SKIP tag that replaces EXT-X-DATERANGE tags in a Playlist
140+
Delta Update.
141+
*/
142+
143+
for _, def := range p.Defines {
144+
if def.Type == QUERYPARAM {
145+
updateMin(&ver, &reason, 11,
146+
"EXT-X-DEFINE tag with a QUERYPARAM attribute")
147+
}
148+
}
149+
150+
return ver, reason
151+
}
152+
153+
// [HLS Prococcol Version Compatibility]: https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-16#section-8
154+
155+
/*
156+
From https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-16
157+
158+
This library only supports level 3 and higher, so we don't check
159+
for level 1 and 2 compatibility.
160+
161+
8. Protocol Version Compatibility
162+
163+
Protocol compatibility is specified by the EXT-X-VERSION tag. A
164+
Playlist that contains tags or attributes that are not compatible
165+
with protocol version 1 MUST include an EXT-X-VERSION tag.
166+
167+
A client MUST NOT attempt playback if it does not support the
168+
protocol version specified by the EXT-X-VERSION tag, or unintended
169+
behavior could occur.
170+
171+
A Media Playlist MUST indicate an EXT-X-VERSION of 2 or higher if it
172+
contains:
173+
174+
* The IV attribute of the EXT-X-KEY tag.
175+
176+
A Media Playlist MUST indicate an EXT-X-VERSION of 3 or higher if it
177+
contains:
178+
179+
* Floating-point EXTINF duration values.
180+
181+
A Media Playlist MUST indicate an EXT-X-VERSION of 4 or higher if it
182+
contains:
183+
184+
* The EXT-X-BYTERANGE tag.
185+
186+
* The EXT-X-I-FRAMES-ONLY tag.
187+
188+
A Media Playlist MUST indicate an EXT-X-VERSION of 5 or higher if it
189+
contains:
190+
191+
* An EXT-X-KEY tag with a METHOD of SAMPLE-AES.
192+
193+
* The KEYFORMAT and KEYFORMATVERSIONS attributes of the EXT-X-KEY
194+
tag.
195+
196+
* The EXT-X-MAP tag.
197+
198+
A Media Playlist MUST indicate an EXT-X-VERSION of 6 or higher if it
199+
contains:
200+
201+
* The EXT-X-MAP tag in a Media Playlist that does not contain EXT-
202+
X-I-FRAMES-ONLY.
203+
204+
Note that in protocol version 6, the semantics of the EXT-
205+
X-TARGETDURATION tag changed slightly. In protocol version 5 and
206+
earlier it indicated the maximum segment duration; in protocol
207+
version 6 and later it indicates the maximum segment duration rounded
208+
to the nearest integer number of seconds.
209+
210+
A Multivariant Playlist MUST indicate an EXT-X-VERSION of 7 or higher
211+
if it contains:
212+
213+
* "SERVICE" values for the INSTREAM-ID attribute of the EXT-X-MEDIA
214+
tag.
215+
216+
A Playlist MUST indicate an EXT-X-VERSION of 8 or higher if it
217+
contains:
218+
219+
* Variable substitution.
220+
221+
A Playlist MUST indicate an EXT-X-VERSION of 9 or higher if it
222+
contains:
223+
224+
* The EXT-X-SKIP tag.
225+
226+
A Playlist MUST indicate an EXT-X-VERSION of 10 or higher if it
227+
contains:
228+
229+
* An EXT-X-SKIP tag that replaces EXT-X-DATERANGE tags in a Playlist
230+
Delta Update.
231+
232+
A Playlist MUST indicate an EXT-X-VERSION of 11 or higher if it
233+
contains:
234+
235+
* An EXT-X-DEFINE tag with a QUERYPARAM attribute.
236+
237+
A Playlist MUST indicate an EXT-X-VERSION of 12 or higher if it
238+
contains:
239+
240+
* An attribute whose name starts with "REQ-".
241+
242+
The EXT-X-MEDIA tag and the AUDIO, VIDEO, and SUBTITLES attributes of
243+
the EXT-X-STREAM-INF tag are backward compatible to protocol version
244+
1, but playback on older clients may not be desirable. A server MAY
245+
consider indicating an EXT-X-VERSION of 4 or higher in the
246+
Multivariant Playlist but is not required to do so.
247+
248+
The PROGRAM-ID attribute of the EXT-X-STREAM-INF and the EXT-X-I-
249+
FRAME-STREAM-INF tags was removed in protocol version 6.
250+
251+
The EXT-X-ALLOW-CACHE tag was removed in protocol version 7.
252+
*/

m3u8/calcversion_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package m3u8
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"strings"
8+
"testing"
9+
10+
"github.com/matryer/is"
11+
)
12+
13+
func TestCalcMinVersionMasterPlaylist(t *testing.T) {
14+
is := is.New(t)
15+
pl3 := NewMasterPlaylist()
16+
17+
pl7 := NewMasterPlaylist()
18+
pl7.Variants = append(pl7.Variants, &Variant{
19+
VariantParams: VariantParams{
20+
Alternatives: []*Alternative{{InstreamId: "SERVICE1"}},
21+
},
22+
})
23+
24+
pl11, err := readTestMasterPlaylist(t, "sample-playlists/master-with-defines.m3u8")
25+
is.NoErr(err) // must decode sample-playlists/master-with-defines.m3u8
26+
27+
pl12, err := readTestMasterPlaylist(t, "sample-playlists/master-with-req-video-layout.m3u8")
28+
is.NoErr(err) // must decode sample-playlists/master-with-req-video-layout.m3u8
29+
30+
cases := []struct {
31+
playlist Playlist
32+
expectedVersion uint8
33+
expectedReason string
34+
}{
35+
{pl3, minVer, "minimal version supported by this library"},
36+
{pl7, 7, "SERVICE value for the INSTREAM-ID attribute of the EXT-X-MEDIA"},
37+
{pl11, 11, "EXT-X-DEFINE tag with a QUERYPARAM attribute"},
38+
{pl12, 12, "REQ- attribute"},
39+
}
40+
41+
for i, c := range cases {
42+
t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) {
43+
is := is.New(t)
44+
ver, reason := c.playlist.CalcMinVersion()
45+
is.Equal(ver, c.expectedVersion)
46+
is.Equal(reason, c.expectedReason)
47+
})
48+
}
49+
}
50+
51+
func TestCalcMinVersionMediaPlaylist(t *testing.T) {
52+
53+
is := is.New(t)
54+
55+
pl3, err := NewMediaPlaylist(10, 10)
56+
is.NoErr(err) // must create media playlist
57+
58+
pl4ByteRange, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-with-byterange.m3u8")
59+
is.NoErr(err) // must decode sample-playlists/media-playlist-with-byterange.m3u8
60+
61+
pl4IframesOnly, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-with-iframes-only.m3u8")
62+
is.NoErr(err) // must decode sample-playlists/media-playlist-with-iframes-only.m3u8
63+
64+
pl5IframesOnlyAndMap, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-with-iframes-only-and-map.m3u8")
65+
is.NoErr(err) // must decode sample-playlists/media-playlist-with-iframes-only-and-map.m3u8
66+
67+
pl5SampleAES, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-with-key.m3u8")
68+
is.NoErr(err) // must decode sample-playlists/media-playlist-with-key.m3u8
69+
70+
pl6Fmp4, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-fmp4.m3u8")
71+
is.NoErr(err) // must decode sample-playlists/media-playlist-fmp4.m3u8
72+
73+
pl8VariableSubstitution, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-with-defines.m3u8")
74+
is.NoErr(err) // must decode sample-playlists/media-playlist-with-defines.m3u8
75+
76+
pl11QueryParam, err := readTestMediaPlaylist(t, "sample-playlists/media-playlist-with-queryparam.m3u8")
77+
is.NoErr(err) // must decode sample-playlists/media-playlist-with-queryparam.m3u8
78+
79+
cases := []struct {
80+
playlist Playlist
81+
expectedVersion uint8
82+
expectedReason string
83+
}{
84+
{pl3, minVer, "minimal version supported by this library"},
85+
{pl4ByteRange, 4, "EXT-X-BYTERANGE tag"},
86+
{pl4IframesOnly, 4, "EXT-X-I-FRAMES-ONLY tag"},
87+
{pl5IframesOnlyAndMap, 5, "EXT-X-MAP tag"},
88+
{pl5SampleAES, 5, "EXT-X-KEY tag with a METHOD of SAMPLE-AES, KEYFORMAT or KEYFORMATVERSIONS attributes"},
89+
{pl6Fmp4, 6, "EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY"},
90+
{pl8VariableSubstitution, 8, "Variable substitution"},
91+
{pl11QueryParam, 11, "EXT-X-DEFINE tag with a QUERYPARAM attribute"},
92+
}
93+
94+
for i, c := range cases {
95+
t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) {
96+
is := is.New(t)
97+
ver, reason := c.playlist.CalcMinVersion()
98+
is.Equal(ver, c.expectedVersion)
99+
is.Equal(reason, c.expectedReason)
100+
})
101+
}
102+
}
103+
104+
func readTestPlaylist(t *testing.T, fileName string) Playlist {
105+
t.Helper()
106+
f, err := os.Open(fileName)
107+
if err != nil {
108+
t.Fail()
109+
}
110+
defer f.Close()
111+
112+
p, _, err := DecodeFrom(bufio.NewReader(f), false)
113+
if err != nil {
114+
t.Fail()
115+
}
116+
return p
117+
}
118+
119+
func TestAllPlaylistVersions(t *testing.T) {
120+
is := is.New(t)
121+
122+
// Read all m3u8 files in sample-playlists directory
123+
files, err := os.ReadDir("sample-playlists")
124+
is.NoErr(err)
125+
126+
for _, file := range files {
127+
fName := file.Name()
128+
if !strings.HasSuffix(fName, ".m3u8") {
129+
continue
130+
}
131+
132+
t.Run(fName, func(t *testing.T) {
133+
path := "sample-playlists/" + fName
134+
135+
p := readTestPlaylist(t, path)
136+
137+
minVer, reason := p.CalcMinVersion()
138+
actualVer := p.Version()
139+
if minVer > actualVer {
140+
t.Errorf("Playlist %s: CalcMinVersion=%d but Version=%d (reason: %s)",
141+
fName, minVer, actualVer, reason)
142+
}
143+
})
144+
}
145+
}

0 commit comments

Comments
 (0)