m3u8 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +5 -14
- data/.travis.yml +3 -3
- data/CHANGELOG.md +11 -2
- data/README.md +26 -58
- data/lib/m3u8/byte_range.rb +2 -2
- data/lib/m3u8/discontinuity_item.rb +0 -2
- data/lib/m3u8/error.rb +4 -1
- data/lib/m3u8/map_item.rb +2 -2
- data/lib/m3u8/media_item.rb +39 -14
- data/lib/m3u8/playlist.rb +26 -15
- data/lib/m3u8/playlist_item.rb +13 -7
- data/lib/m3u8/reader.rb +22 -3
- data/lib/m3u8/time_item.rb +1 -1
- data/lib/m3u8/version.rb +1 -1
- data/lib/m3u8/writer.rb +20 -9
- data/spec/fixtures/playlist.m3u8 +1 -0
- data/spec/fixtures/session_data.m3u8 +1 -0
- data/spec/lib/m3u8/discontinuity_item_spec.rb +1 -3
- data/spec/lib/m3u8/media_item_spec.rb +80 -33
- data/spec/lib/m3u8/playlist_item_spec.rb +105 -79
- data/spec/lib/m3u8/playlist_spec.rb +211 -190
- data/spec/lib/m3u8/reader_spec.rb +33 -23
- data/spec/lib/m3u8/writer_spec.rb +221 -147
- metadata +3 -3
data/lib/m3u8/reader.rb
CHANGED
@@ -14,8 +14,9 @@ module M3u8
|
|
14
14
|
end
|
15
15
|
|
16
16
|
def read(input)
|
17
|
-
|
18
|
-
input.each_line do |line|
|
17
|
+
@playlist = Playlist.new
|
18
|
+
input.each_line.with_index do |line, index|
|
19
|
+
validate_file_format(line) if index.zero?
|
19
20
|
parse_line(line)
|
20
21
|
end
|
21
22
|
playlist
|
@@ -42,6 +43,9 @@ module M3u8
|
|
42
43
|
def media_playlist_tags
|
43
44
|
{
|
44
45
|
'#EXT-X-MEDIA-SEQUENCE' => ->(line) { parse_sequence(line) },
|
46
|
+
'#EXT-X-DISCONTINUITY-SEQUENCE' => lambda do |line|
|
47
|
+
parse_discontinuity_sequence(line)
|
48
|
+
end,
|
45
49
|
'#EXT-X-ALLOW-CACHE' => ->(line) { parse_cache(line) },
|
46
50
|
'#EXT-X-TARGETDURATION' => ->(line) { parse_target(line) },
|
47
51
|
'#EXT-X-I-FRAMES-ONLY' => proc { playlist.iframes_only = true },
|
@@ -74,7 +78,10 @@ module M3u8
|
|
74
78
|
end
|
75
79
|
|
76
80
|
def match_tag(line)
|
77
|
-
tag = @tags.select
|
81
|
+
tag = @tags.select do |key|
|
82
|
+
line.start_with?(key) && !line.start_with?("#{key}-")
|
83
|
+
end
|
84
|
+
|
78
85
|
return unless tag.values.first
|
79
86
|
tag.values.first.call(line)
|
80
87
|
true
|
@@ -125,6 +132,11 @@ module M3u8
|
|
125
132
|
playlist.items << item
|
126
133
|
end
|
127
134
|
|
135
|
+
def parse_discontinuity_sequence(line)
|
136
|
+
value = line.gsub('#EXT-X-DISCONTINUITY-SEQUENCE:', '').strip
|
137
|
+
playlist.discontinuity_sequence = Integer(value)
|
138
|
+
end
|
139
|
+
|
128
140
|
def parse_key(line)
|
129
141
|
item = M3u8::KeyItem.parse(line)
|
130
142
|
playlist.items << item
|
@@ -197,5 +209,12 @@ module M3u8
|
|
197
209
|
playlist.items << item
|
198
210
|
self.open = false
|
199
211
|
end
|
212
|
+
|
213
|
+
def validate_file_format(line)
|
214
|
+
return if line.rstrip == '#EXTM3U'
|
215
|
+
message = 'Playlist must start with a #EXTM3U tag, line read ' \
|
216
|
+
"contained the value: #{line}"
|
217
|
+
raise InvalidPlaylistError, message
|
218
|
+
end
|
200
219
|
end
|
201
220
|
end
|
data/lib/m3u8/time_item.rb
CHANGED
data/lib/m3u8/version.rb
CHANGED
data/lib/m3u8/writer.rb
CHANGED
@@ -5,7 +5,7 @@ module M3u8
|
|
5
5
|
attr_accessor :io
|
6
6
|
|
7
7
|
def initialize(io)
|
8
|
-
|
8
|
+
@io = io
|
9
9
|
end
|
10
10
|
|
11
11
|
def write(playlist)
|
@@ -16,10 +16,23 @@ module M3u8
|
|
16
16
|
io.puts item.to_s
|
17
17
|
end
|
18
18
|
|
19
|
-
|
19
|
+
write_footer(playlist)
|
20
|
+
end
|
21
|
+
|
22
|
+
def write_footer(playlist)
|
23
|
+
return if playlist.live? || playlist.master?
|
20
24
|
io.puts '#EXT-X-ENDLIST'
|
21
25
|
end
|
22
26
|
|
27
|
+
def write_header(playlist)
|
28
|
+
io.puts '#EXTM3U'
|
29
|
+
if playlist.master?
|
30
|
+
write_master_playlist_header(playlist)
|
31
|
+
else
|
32
|
+
write_media_playlist_header(playlist)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
23
36
|
private
|
24
37
|
|
25
38
|
def target_duration_format(playlist)
|
@@ -37,13 +50,10 @@ module M3u8
|
|
37
50
|
io.puts "#EXT-X-ALLOW-CACHE:#{cache ? 'YES' : 'NO'}"
|
38
51
|
end
|
39
52
|
|
40
|
-
def
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
else
|
45
|
-
write_media_playlist_header(playlist)
|
46
|
-
end
|
53
|
+
def write_discontinuity_sequence_tag(sequence)
|
54
|
+
return if sequence.nil?
|
55
|
+
|
56
|
+
io.puts "#EXT-X-DISCONTINUITY-SEQUENCE:#{sequence}"
|
47
57
|
end
|
48
58
|
|
49
59
|
def write_independent_segments_tag(independent_segments)
|
@@ -63,6 +73,7 @@ module M3u8
|
|
63
73
|
write_independent_segments_tag(playlist.independent_segments)
|
64
74
|
io.puts '#EXT-X-I-FRAMES-ONLY' if playlist.iframes_only
|
65
75
|
io.puts "#EXT-X-MEDIA-SEQUENCE:#{playlist.sequence}"
|
76
|
+
write_discontinuity_sequence_tag(playlist.discontinuity_sequence)
|
66
77
|
write_cache_tag(playlist.cache)
|
67
78
|
io.puts target_duration_format(playlist)
|
68
79
|
end
|
data/spec/fixtures/playlist.m3u8
CHANGED
@@ -4,8 +4,6 @@ require 'spec_helper'
|
|
4
4
|
describe M3u8::DiscontinuityItem do
|
5
5
|
it 'should provide m3u8 format representation' do
|
6
6
|
item = M3u8::DiscontinuityItem.new
|
7
|
-
|
8
|
-
expected = "#EXT-X-DISCONTINUITY\n"
|
9
|
-
expect(output).to eq expected
|
7
|
+
expect(item.to_s).to eq("#EXT-X-DISCONTINUITY\n")
|
10
8
|
end
|
11
9
|
end
|
@@ -2,42 +2,89 @@
|
|
2
2
|
require 'spec_helper'
|
3
3
|
|
4
4
|
describe M3u8::MediaItem do
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
'URI="frelo/prog_index.m3u8"'
|
14
|
-
expect(output).to eq expected
|
5
|
+
describe '.new' do
|
6
|
+
it 'assigns attributes from options' do
|
7
|
+
options = { type: 'AUDIO', group_id: 'audio-lo', language: 'fre',
|
8
|
+
assoc_language: 'spoken', name: 'Francais', autoselect: true,
|
9
|
+
default: false, forced: true, uri: 'frelo/prog_index.m3u8',
|
10
|
+
instream_id: 'SERVICE3', characteristics: 'public.html',
|
11
|
+
channels: '6' }
|
12
|
+
item = described_class.new(options)
|
15
13
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
14
|
+
expect(item.type).to eq('AUDIO')
|
15
|
+
expect(item.group_id).to eq('audio-lo')
|
16
|
+
expect(item.language).to eq('fre')
|
17
|
+
expect(item.assoc_language).to eq('spoken')
|
18
|
+
expect(item.name).to eq('Francais')
|
19
|
+
expect(item.autoselect).to be true
|
20
|
+
expect(item.default).to be false
|
21
|
+
expect(item.uri).to eq('frelo/prog_index.m3u8')
|
22
|
+
expect(item.forced).to be true
|
23
|
+
expect(item.instream_id).to eq('SERVICE3')
|
24
|
+
expect(item.characteristics).to eq('public.html')
|
25
|
+
expect(item.channels).to eq('6')
|
26
|
+
end
|
25
27
|
end
|
26
28
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
29
|
+
describe '.parse' do
|
30
|
+
it 'returns instance from parsed tag' do
|
31
|
+
tag = '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="fre",' \
|
32
|
+
'ASSOC-LANGUAGE="spoken",NAME="Francais",AUTOSELECT=YES,' \
|
33
|
+
'INSTREAM-ID="SERVICE3",CHARACTERISTICS="public.html",' \
|
34
|
+
'CHANNELS="6",' +
|
35
|
+
%("DEFAULT=NO,URI="frelo/prog_index.m3u8",FORCED=YES\n")
|
36
|
+
item = described_class.parse(tag)
|
32
37
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
38
|
+
expect(item.type).to eq('AUDIO')
|
39
|
+
expect(item.group_id).to eq('audio-lo')
|
40
|
+
expect(item.language).to eq('fre')
|
41
|
+
expect(item.assoc_language).to eq('spoken')
|
42
|
+
expect(item.name).to eq('Francais')
|
43
|
+
expect(item.autoselect).to be true
|
44
|
+
expect(item.default).to be false
|
45
|
+
expect(item.uri).to eq('frelo/prog_index.m3u8')
|
46
|
+
expect(item.forced).to be true
|
47
|
+
expect(item.instream_id).to eq('SERVICE3')
|
48
|
+
expect(item.characteristics).to eq('public.html')
|
49
|
+
expect(item.channels).to eq('6')
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
describe '#to_s' do
|
54
|
+
context 'when no attributes are assigned' do
|
55
|
+
it 'returns default tag text' do
|
56
|
+
item = described_class.new
|
57
|
+
expected = '#EXT-X-MEDIA:TYPE=,GROUP-ID="",NAME=""'
|
58
|
+
expect(item.to_s).to eq(expected)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
context 'when only required attributes are assigned' do
|
63
|
+
it 'returns tag text' do
|
64
|
+
options = { type: 'AUDIO', group_id: 'audio-lo', name: 'Francais' }
|
65
|
+
item = described_class.new(options)
|
66
|
+
expected = '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",NAME="Francais"'
|
67
|
+
expect(item.to_s).to eq(expected)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
context 'when all attributes are assigned' do
|
72
|
+
it 'returns tag text' do
|
73
|
+
options = { type: 'AUDIO', group_id: 'audio-lo', language: 'fre',
|
74
|
+
assoc_language: 'spoken', name: 'Francais',
|
75
|
+
autoselect: true, default: false, forced: true,
|
76
|
+
uri: 'frelo/prog_index.m3u8', instream_id: 'SERVICE3',
|
77
|
+
characteristics: 'public.html', channels: '6' }
|
78
|
+
item = M3u8::MediaItem.new(options)
|
79
|
+
output = item.to_s
|
80
|
+
expected = '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",' \
|
81
|
+
'LANGUAGE="fre",ASSOC-LANGUAGE="spoken",' \
|
82
|
+
'NAME="Francais",AUTOSELECT=YES,' \
|
83
|
+
'DEFAULT=NO,URI="frelo/prog_index.m3u8",FORCED=YES,' \
|
84
|
+
'INSTREAM-ID="SERVICE3",CHARACTERISTICS="public.html",' \
|
85
|
+
'CHANNELS="6"'
|
86
|
+
expect(output).to eq(expected)
|
87
|
+
end
|
88
|
+
end
|
42
89
|
end
|
43
90
|
end
|
@@ -2,27 +2,58 @@
|
|
2
2
|
require 'spec_helper'
|
3
3
|
|
4
4
|
describe M3u8::PlaylistItem do
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
5
|
+
describe '.new' do
|
6
|
+
it 'assigns attributes from options' do
|
7
|
+
options = { program_id: 1, width: 1920, height: 1080, codecs: 'avc',
|
8
|
+
bandwidth: 540, audio_codec: 'mp3', level: '2',
|
9
|
+
profile: 'baseline', video: 'test_video', audio: 'test_a',
|
10
|
+
uri: 'test.url', average_bandwidth: 500, subtitles: 'subs',
|
11
|
+
closed_captions: 'cc', iframe: true, frame_rate: 24.6,
|
12
|
+
name: 'test_name', hdcp_level: 'TYPE-0' }
|
13
|
+
item = described_class.new(options)
|
14
|
+
|
15
|
+
expect(item.program_id).to eq(1)
|
16
|
+
expect(item.width).to eq(1920)
|
17
|
+
expect(item.height).to eq(1080)
|
18
|
+
expect(item.resolution).to eq('1920x1080')
|
19
|
+
expect(item.codecs).to eq('avc')
|
20
|
+
expect(item.bandwidth).to eq(540)
|
21
|
+
expect(item.audio_codec).to eq('mp3')
|
22
|
+
expect(item.level).to eq('2')
|
23
|
+
expect(item.profile).to eq('baseline')
|
24
|
+
expect(item.video).to eq('test_video')
|
25
|
+
expect(item.audio).to eq('test_a')
|
26
|
+
expect(item.uri).to eq('test.url')
|
27
|
+
expect(item.average_bandwidth).to eq(500)
|
28
|
+
expect(item.subtitles).to eq('subs')
|
29
|
+
expect(item.closed_captions).to eq('cc')
|
30
|
+
expect(item.iframe).to be true
|
31
|
+
expect(item.frame_rate).to eq(24.6)
|
32
|
+
expect(item.name).to eq('test_name')
|
33
|
+
expect(item.hdcp_level).to eq('TYPE-0')
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe '.parse' do
|
38
|
+
it 'returns new instance from parsed tag' do
|
39
|
+
tag = %(#EXT-X-STREAM-INF:CODECS="avc",BANDWIDTH=540,) +
|
40
|
+
%(PROGRAM-ID=1,RESOLUTION=1920x1080,FRAME-RATE=23.976,) +
|
41
|
+
%(AVERAGE-BANDWIDTH=550,AUDIO="test",VIDEO="test2",) +
|
42
|
+
%(SUBTITLES="subs",CLOSED-CAPTIONS="caps",URI="test.url",) +
|
43
|
+
%(NAME="1080p",HDCP-LEVEL=TYPE-0)
|
44
|
+
expect_any_instance_of(described_class).to receive(:parse).with(tag)
|
45
|
+
item = described_class.parse(tag)
|
46
|
+
expect(item).to be_a(described_class)
|
47
|
+
end
|
17
48
|
end
|
18
49
|
|
19
|
-
describe 'parse' do
|
20
|
-
it '
|
50
|
+
describe '#parse' do
|
51
|
+
it 'assigns values from parsed tag' do
|
21
52
|
input = %(#EXT-X-STREAM-INF:CODECS="avc",BANDWIDTH=540,) +
|
22
53
|
%(PROGRAM-ID=1,RESOLUTION=1920x1080,FRAME-RATE=23.976,) +
|
23
54
|
%(AVERAGE-BANDWIDTH=550,AUDIO="test",VIDEO="test2",) +
|
24
55
|
%(SUBTITLES="subs",CLOSED-CAPTIONS="caps",URI="test.url",) +
|
25
|
-
%(NAME="1080p")
|
56
|
+
%(NAME="1080p",HDCP-LEVEL=TYPE-0)
|
26
57
|
item = M3u8::PlaylistItem.parse(input)
|
27
58
|
expect(item.program_id).to eq '1'
|
28
59
|
expect(item.codecs).to eq 'avc'
|
@@ -37,70 +68,72 @@ describe M3u8::PlaylistItem do
|
|
37
68
|
expect(item.closed_captions).to eq 'caps'
|
38
69
|
expect(item.uri).to eq 'test.url'
|
39
70
|
expect(item.name).to eq '1080p'
|
71
|
+
expect(item.iframe).to be false
|
72
|
+
expect(item.hdcp_level).to eq('TYPE-0')
|
40
73
|
end
|
74
|
+
end
|
41
75
|
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
expect(item.codecs).to eq 'avc'
|
51
|
-
expect(item.bandwidth).to eq 540
|
52
|
-
expect(item.average_bandwidth).to be_nil
|
53
|
-
expect(item.width).to be_nil
|
54
|
-
expect(item.height).to be_nil
|
55
|
-
expect(item.audio).to eq 'test'
|
56
|
-
expect(item.video).to eq 'test2'
|
57
|
-
expect(item.subtitles).to eq 'subs'
|
58
|
-
expect(item.closed_captions).to eq 'caps'
|
59
|
-
expect(item.uri).to eq 'test.url'
|
60
|
-
expect(item.name).to eq 'SD'
|
76
|
+
describe '#to_s' do
|
77
|
+
context 'when codecs is missing' do
|
78
|
+
it 'raises error' do
|
79
|
+
params = { bandwidth: 540, uri: 'test.url' }
|
80
|
+
item = M3u8::PlaylistItem.new params
|
81
|
+
message = 'Audio or video codec info should be provided.'
|
82
|
+
expect { item.to_s }.to raise_error(M3u8::MissingCodecError, message)
|
83
|
+
end
|
61
84
|
end
|
62
|
-
end
|
63
85
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
86
|
+
context 'when only required attributes are present' do
|
87
|
+
it 'returns tag' do
|
88
|
+
options = { codecs: 'avc', bandwidth: 540,
|
89
|
+
uri: 'test.url' }
|
90
|
+
item = described_class.new(options)
|
91
|
+
expected = %(#EXT-X-STREAM-INF:CODECS="avc",BANDWIDTH=540) +
|
92
|
+
"\ntest.url"
|
93
|
+
expect(item.to_s).to eq(expected)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
context 'when all attributes are present' do
|
98
|
+
it 'returns tag' do
|
99
|
+
options = { codecs: 'avc', bandwidth: 540, uri: 'test.url',
|
100
|
+
audio: 'test', video: 'test2', average_bandwidth: 500,
|
101
|
+
subtitles: 'subs', frame_rate: 30, closed_captions: 'caps',
|
102
|
+
name: 'SD', hdcp_level: 'TYPE-0', program_id: '1' }
|
103
|
+
item = described_class.new(options)
|
104
|
+
expected = %(#EXT-X-STREAM-INF:PROGRAM-ID=1,CODECS="avc",BANDWIDTH=540,) +
|
105
|
+
%(AVERAGE-BANDWIDTH=500,FRAME-RATE=30.000,) +
|
106
|
+
'HDCP-LEVEL=TYPE-0,' +
|
107
|
+
%(AUDIO="test",VIDEO="test2",SUBTITLES="subs",) +
|
108
|
+
%(CLOSED-CAPTIONS="caps",NAME="SD"\ntest.url)
|
109
|
+
expect(item.to_s).to eq(expected)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
context 'when closed captions is NONE' do
|
114
|
+
it 'returns tag' do
|
115
|
+
options = { program_id: 1, width: 1920, height: 1080, codecs: 'avc',
|
116
|
+
bandwidth: 540, uri: 'test.url', closed_captions: 'NONE' }
|
117
|
+
item = described_class.new(options)
|
118
|
+
expected = '#EXT-X-STREAM-INF:PROGRAM-ID=1,RESOLUTION=1920x1080,' +
|
119
|
+
%(CODECS="avc",BANDWIDTH=540,CLOSED-CAPTIONS=NONE\ntest.url)
|
120
|
+
expect(item.to_s).to eq(expected)
|
121
|
+
end
|
122
|
+
end
|
92
123
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
124
|
+
context 'when iframe is enabled' do
|
125
|
+
it 'returns EXT-X-I-FRAME-STREAM-INF tag' do
|
126
|
+
options = { codecs: 'avc', bandwidth: 540, uri: 'test.url',
|
127
|
+
iframe: true, video: 'test2', average_bandwidth: 550 }
|
128
|
+
item = described_class.new(options)
|
129
|
+
expected = %(#EXT-X-I-FRAME-STREAM-INF:CODECS="avc",BANDWIDTH=540,) +
|
130
|
+
%(AVERAGE-BANDWIDTH=550,VIDEO="test2",URI="test.url")
|
131
|
+
expect(item.to_s).to eq(expected)
|
132
|
+
end
|
133
|
+
end
|
101
134
|
end
|
102
135
|
|
103
|
-
it '
|
136
|
+
it 'generates codecs string' do
|
104
137
|
item = M3u8::PlaylistItem.new
|
105
138
|
expect(item.codecs).to be_nil
|
106
139
|
|
@@ -180,11 +213,4 @@ describe M3u8::PlaylistItem do
|
|
180
213
|
item = M3u8::PlaylistItem.new options
|
181
214
|
expect(item.codecs).to eq 'avc1.640029'
|
182
215
|
end
|
183
|
-
|
184
|
-
it 'should raise error if codecs are missing' do
|
185
|
-
params = { program_id: 1, bandwidth: 540, uri: 'test.url' }
|
186
|
-
item = M3u8::PlaylistItem.new params
|
187
|
-
message = 'Audio or video codec info should be provided.'
|
188
|
-
expect { item.to_s }.to raise_error(M3u8::MissingCodecError, message)
|
189
|
-
end
|
190
216
|
end
|