m3u8 1.8.0 → 1.9.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/CHANGELOG.md +28 -0
- data/README.md +89 -0
- data/lib/m3u8/attribute_formatter.rb +17 -0
- data/lib/m3u8/bitrate_item.rb +2 -0
- data/lib/m3u8/byte_range.rb +2 -0
- data/lib/m3u8/cli/diff_command.rb +61 -0
- data/lib/m3u8/cli/inspect_command.rb +9 -1
- data/lib/m3u8/cli/validate_command.rb +16 -0
- data/lib/m3u8/cli.rb +45 -11
- data/lib/m3u8/content_steering_item.rb +1 -0
- data/lib/m3u8/date_range_item.rb +6 -5
- data/lib/m3u8/define_item.rb +1 -0
- data/lib/m3u8/discontinuity_item.rb +2 -0
- data/lib/m3u8/encryptable.rb +10 -0
- data/lib/m3u8/gap_item.rb +2 -0
- data/lib/m3u8/key_item.rb +1 -8
- data/lib/m3u8/map_item.rb +1 -0
- data/lib/m3u8/media_item.rb +1 -0
- data/lib/m3u8/part_inf_item.rb +1 -0
- data/lib/m3u8/part_item.rb +2 -1
- data/lib/m3u8/playback_start.rb +1 -0
- data/lib/m3u8/playlist.rb +236 -2
- data/lib/m3u8/playlist_item.rb +1 -0
- data/lib/m3u8/preload_hint_item.rb +1 -0
- data/lib/m3u8/reader.rb +15 -2
- data/lib/m3u8/rendition_report_item.rb +1 -0
- data/lib/m3u8/segment_item.rb +3 -1
- data/lib/m3u8/serializable.rb +43 -0
- data/lib/m3u8/server_control_item.rb +1 -0
- data/lib/m3u8/session_data_item.rb +1 -0
- data/lib/m3u8/session_key_item.rb +1 -8
- data/lib/m3u8/skip_item.rb +1 -0
- data/lib/m3u8/time_item.rb +1 -0
- data/lib/m3u8/variable_resolver.rb +83 -0
- data/lib/m3u8/version.rb +1 -1
- data/lib/m3u8.rb +2 -0
- data/m3u8.gemspec +1 -0
- data/spec/lib/m3u8/cli/diff_command_spec.rb +49 -0
- data/spec/lib/m3u8/cli/inspect_command_spec.rb +12 -0
- data/spec/lib/m3u8/cli/validate_command_spec.rb +17 -1
- data/spec/lib/m3u8/cli_spec.rb +47 -1
- data/spec/lib/m3u8/conformance_spec.rb +123 -0
- data/spec/lib/m3u8/date_range_item_spec.rb +20 -0
- data/spec/lib/m3u8/part_item_spec.rb +7 -0
- data/spec/lib/m3u8/reader_spec.rb +35 -0
- data/spec/lib/m3u8/round_trip_spec.rb +110 -0
- data/spec/lib/m3u8/segment_item_spec.rb +11 -0
- data/spec/lib/m3u8/serializable_spec.rb +133 -0
- data/spec/lib/m3u8/time_item_spec.rb +13 -0
- data/spec/lib/m3u8/variable_resolver_spec.rb +104 -0
- metadata +24 -3
|
@@ -13,7 +13,7 @@ describe M3u8::CLI::ValidateCommand do
|
|
|
13
13
|
)
|
|
14
14
|
code = described_class.new(playlist, stdout).run
|
|
15
15
|
expect(code).to eq(0)
|
|
16
|
-
expect(stdout.string
|
|
16
|
+
expect(stdout.string).to start_with('Valid')
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
@@ -35,5 +35,21 @@ describe M3u8::CLI::ValidateCommand do
|
|
|
35
35
|
)
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
|
+
|
|
39
|
+
context 'when playlist has warnings' do
|
|
40
|
+
it 'prints warnings after the validity result' do
|
|
41
|
+
playlist = M3u8::Playlist.read(
|
|
42
|
+
File.read('spec/fixtures/map_playlist.m3u8')
|
|
43
|
+
)
|
|
44
|
+
code = described_class.new(playlist, stdout).run
|
|
45
|
+
expect(code).to eq(0)
|
|
46
|
+
lines = stdout.string.split("\n")
|
|
47
|
+
expect(lines).to include('Valid')
|
|
48
|
+
expect(lines).to include('Warnings:')
|
|
49
|
+
expect(lines).to include(
|
|
50
|
+
' - EXT-X-MAP requires version 6 (version 5)'
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
38
54
|
end
|
|
39
55
|
end
|
data/spec/lib/m3u8/cli_spec.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'json'
|
|
4
5
|
|
|
5
6
|
describe M3u8::CLI do
|
|
6
7
|
let(:stdout) { StringIO.new }
|
|
@@ -24,6 +25,7 @@ describe M3u8::CLI do
|
|
|
24
25
|
expect(stdout.string).to include('Usage: m3u8')
|
|
25
26
|
expect(stdout.string).to include('inspect')
|
|
26
27
|
expect(stdout.string).to include('validate')
|
|
28
|
+
expect(stdout.string).to include('diff')
|
|
27
29
|
end
|
|
28
30
|
end
|
|
29
31
|
|
|
@@ -64,7 +66,7 @@ describe M3u8::CLI do
|
|
|
64
66
|
it 'reads a playlist from a file for validate' do
|
|
65
67
|
code = run(['validate', 'spec/fixtures/master.m3u8'])
|
|
66
68
|
expect(code).to eq(0)
|
|
67
|
-
expect(stdout.string
|
|
69
|
+
expect(stdout.string).to start_with('Valid')
|
|
68
70
|
end
|
|
69
71
|
end
|
|
70
72
|
|
|
@@ -79,6 +81,50 @@ describe M3u8::CLI do
|
|
|
79
81
|
end
|
|
80
82
|
end
|
|
81
83
|
|
|
84
|
+
describe 'inspect --json' do
|
|
85
|
+
it 'prints the playlist as JSON and exits 0' do
|
|
86
|
+
code = run(['inspect', '--json', 'spec/fixtures/master.m3u8'])
|
|
87
|
+
expect(code).to eq(0)
|
|
88
|
+
expect(JSON.parse(stdout.string)['master']).to be(true)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
describe 'validate --json' do
|
|
93
|
+
it 'rejects the flag and exits 2' do
|
|
94
|
+
code = run(['validate', '--json', 'spec/fixtures/master.m3u8'])
|
|
95
|
+
expect(code).to eq(2)
|
|
96
|
+
expect(stderr.string).to include('--json is only supported for inspect')
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
describe 'diff' do
|
|
101
|
+
it 'reports identical playlists and exits 0' do
|
|
102
|
+
code = run(['diff', 'spec/fixtures/master.m3u8',
|
|
103
|
+
'spec/fixtures/master.m3u8'])
|
|
104
|
+
expect(code).to eq(0)
|
|
105
|
+
expect(stdout.string.strip).to eq('Identical')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'reports differences and exits 1' do
|
|
109
|
+
code = run(['diff', 'spec/fixtures/playlist.m3u8',
|
|
110
|
+
'spec/fixtures/event_playlist.m3u8'])
|
|
111
|
+
expect(code).to eq(1)
|
|
112
|
+
expect(stdout.string).to include('=>')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'requires two files and exits 2' do
|
|
116
|
+
code = run(['diff', 'spec/fixtures/master.m3u8'])
|
|
117
|
+
expect(code).to eq(2)
|
|
118
|
+
expect(stderr.string).to include('diff requires two files')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'reports a missing file and exits 2' do
|
|
122
|
+
code = run(['diff', 'spec/fixtures/master.m3u8', 'nope.m3u8'])
|
|
123
|
+
expect(code).to eq(2)
|
|
124
|
+
expect(stderr.string).to include('no such file')
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
82
128
|
describe 'stdin input' do
|
|
83
129
|
it 'reads a playlist from stdin' do
|
|
84
130
|
content = File.read('spec/fixtures/master.m3u8')
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe 'M3u8::Playlist#warnings' do
|
|
6
|
+
def low_latency(part_target:, hold_back:, skip_until: nil, target: 10)
|
|
7
|
+
playlist = M3u8::Playlist.new(version: 9, target: target)
|
|
8
|
+
playlist.part_inf = M3u8::PartInfItem.new(part_target: part_target)
|
|
9
|
+
playlist.server_control = M3u8::ServerControlItem.new(
|
|
10
|
+
part_hold_back: hold_back, can_skip_until: skip_until
|
|
11
|
+
)
|
|
12
|
+
playlist
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
context 'version compatibility' do
|
|
16
|
+
it 'warns when a feature requires a higher version' do
|
|
17
|
+
playlist = M3u8::Playlist.read(
|
|
18
|
+
File.read('spec/fixtures/map_playlist.m3u8')
|
|
19
|
+
)
|
|
20
|
+
expect(playlist.warnings)
|
|
21
|
+
.to include('EXT-X-MAP requires version 6 (version 5)')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'warns about floating-point EXTINF below version 3' do
|
|
25
|
+
playlist = M3u8::Playlist.new(version: 1, target: 10)
|
|
26
|
+
playlist.items << M3u8::SegmentItem.new(duration: 4.0, segment: 'a.ts')
|
|
27
|
+
expect(playlist.warnings)
|
|
28
|
+
.to include('floating-point EXTINF requires version 3 (version 1)')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'does not warn about integer EXTINF durations' do
|
|
32
|
+
playlist = M3u8::Playlist.new(version: 1, target: 10)
|
|
33
|
+
playlist.items << M3u8::SegmentItem.new(duration: 4, segment: 'a.ts')
|
|
34
|
+
expect(playlist.warnings).to be_empty
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'warns about LL-HLS tags below version 9' do
|
|
38
|
+
playlist = M3u8::Playlist.new(version: 6, target: 4)
|
|
39
|
+
playlist.part_inf = M3u8::PartInfItem.new(part_target: 0.5)
|
|
40
|
+
playlist.server_control =
|
|
41
|
+
M3u8::ServerControlItem.new(part_hold_back: 1.5)
|
|
42
|
+
expect(playlist.warnings)
|
|
43
|
+
.to include('EXT-X-PART-INF requires version 9 (version 6)')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'warns when a SERVICE INSTREAM-ID needs a higher version' do
|
|
47
|
+
playlist = M3u8::Playlist.new
|
|
48
|
+
playlist.items << M3u8::MediaItem.new(
|
|
49
|
+
type: 'CLOSED-CAPTIONS', group_id: 'cc', name: 'CC',
|
|
50
|
+
instream_id: 'SERVICE1'
|
|
51
|
+
)
|
|
52
|
+
expect(playlist.warnings).to include(
|
|
53
|
+
'SERVICE INSTREAM-ID requires version 7 (no EXT-X-VERSION tag)'
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'warns when EXT-X-SESSION-KEY needs a higher version' do
|
|
58
|
+
playlist = M3u8::Playlist.read(
|
|
59
|
+
File.read('spec/fixtures/master.m3u8')
|
|
60
|
+
)
|
|
61
|
+
expect(playlist.warnings).to include(
|
|
62
|
+
'EXT-X-SESSION-KEY requires version 7 (no EXT-X-VERSION tag)'
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'is empty when the version is sufficient' do
|
|
67
|
+
playlist = M3u8::Playlist.read(
|
|
68
|
+
File.read('spec/fixtures/master_full.m3u8')
|
|
69
|
+
)
|
|
70
|
+
expect(playlist.warnings).to be_empty
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
context 'low-latency HLS' do
|
|
75
|
+
it 'warns when EXT-X-PART lacks EXT-X-PART-INF' do
|
|
76
|
+
playlist = M3u8::Playlist.new(version: 9, target: 4)
|
|
77
|
+
playlist.items << M3u8::PartItem.new(uri: 'p.m4s', duration: 0.5)
|
|
78
|
+
expect(playlist.warnings)
|
|
79
|
+
.to include('EXT-X-PART requires EXT-X-PART-INF')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'does not require PART-HOLD-BACK when it is omitted' do
|
|
83
|
+
playlist = M3u8::Playlist.new(version: 9, target: 4)
|
|
84
|
+
playlist.part_inf = M3u8::PartInfItem.new(part_target: 0.5)
|
|
85
|
+
expect(playlist.warnings).to be_empty
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'warns when PART-HOLD-BACK is below twice PART-TARGET' do
|
|
89
|
+
playlist = low_latency(part_target: 1.0, hold_back: 1.5)
|
|
90
|
+
expect(playlist.warnings)
|
|
91
|
+
.to include('PART-HOLD-BACK should be at least twice PART-TARGET')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'warns when PART-HOLD-BACK is below three times PART-TARGET' do
|
|
95
|
+
playlist = low_latency(part_target: 1.0, hold_back: 2.5)
|
|
96
|
+
expect(playlist.warnings).to include(
|
|
97
|
+
'PART-HOLD-BACK should be at least three times PART-TARGET'
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'warns when CAN-SKIP-UNTIL is below six times TARGETDURATION' do
|
|
102
|
+
playlist = low_latency(part_target: 1.0, hold_back: 3.0,
|
|
103
|
+
skip_until: 10.0, target: 4)
|
|
104
|
+
expect(playlist.warnings).to include(
|
|
105
|
+
'CAN-SKIP-UNTIL should be at least six times TARGETDURATION'
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'is empty when LL-HLS thresholds are met' do
|
|
110
|
+
playlist = low_latency(part_target: 1.0, hold_back: 3.0,
|
|
111
|
+
skip_until: 24.0, target: 4)
|
|
112
|
+
expect(playlist.warnings).to be_empty
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'skips the PART-HOLD-BACK check without a PART-TARGET' do
|
|
116
|
+
playlist = M3u8::Playlist.new(version: 9, target: 4)
|
|
117
|
+
playlist.part_inf = M3u8::PartInfItem.new
|
|
118
|
+
playlist.server_control =
|
|
119
|
+
M3u8::ServerControlItem.new(part_hold_back: 1.0)
|
|
120
|
+
expect(playlist.warnings).to be_empty
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -172,6 +172,26 @@ describe M3u8::DateRangeItem do
|
|
|
172
172
|
expect(item.to_s).to eq(expected)
|
|
173
173
|
end
|
|
174
174
|
|
|
175
|
+
it 'should render small float values as floating-point number instead of scientific notation' do
|
|
176
|
+
options = { id: 'test_id', start_date: '2014-03-05T11:15:00Z',
|
|
177
|
+
duration: 0.00001,
|
|
178
|
+
planned_duration: 0.00002,
|
|
179
|
+
resume_offset: 0.00003,
|
|
180
|
+
playout_limit: 0.00004,
|
|
181
|
+
client_attributes: { 'X-CUSTOM' => 0.00005 } }
|
|
182
|
+
item = described_class.new(options)
|
|
183
|
+
|
|
184
|
+
expected = '#EXT-X-DATERANGE:ID="test_id",' \
|
|
185
|
+
'START-DATE="2014-03-05T11:15:00Z",' \
|
|
186
|
+
'DURATION=0.00001,' \
|
|
187
|
+
'PLANNED-DURATION=0.00002,' \
|
|
188
|
+
'X-CUSTOM=0.00005,' \
|
|
189
|
+
'X-RESUME-OFFSET=0.00003,' \
|
|
190
|
+
'X-PLAYOUT-LIMIT=0.00004'
|
|
191
|
+
|
|
192
|
+
expect(item.to_s).to eq(expected)
|
|
193
|
+
end
|
|
194
|
+
|
|
175
195
|
it 'should ignore optional attributes' do
|
|
176
196
|
options = { id: 'test_id', start_date: '2014-03-05T11:15:00Z' }
|
|
177
197
|
item = described_class.new(options)
|
|
@@ -56,5 +56,12 @@ describe M3u8::PartItem do
|
|
|
56
56
|
expected = '#EXT-X-PART:DURATION=1.5,URI="part1.ts"'
|
|
57
57
|
expect(item.to_s).to eq(expected)
|
|
58
58
|
end
|
|
59
|
+
|
|
60
|
+
it 'should render small float values as floating-point number instead of scientific notation' do
|
|
61
|
+
options = { duration: 0.00001, uri: 'part1.ts' }
|
|
62
|
+
item = described_class.new(options)
|
|
63
|
+
expected = '#EXT-X-PART:DURATION=0.00001,URI="part1.ts"'
|
|
64
|
+
expect(item.to_s).to eq(expected)
|
|
65
|
+
end
|
|
59
66
|
end
|
|
60
67
|
end
|
|
@@ -612,5 +612,40 @@ describe M3u8::Reader do
|
|
|
612
612
|
.to raise_error(M3u8::InvalidPlaylistError, message)
|
|
613
613
|
end
|
|
614
614
|
end
|
|
615
|
+
|
|
616
|
+
context 'with unsupported tags' do
|
|
617
|
+
let(:content) do
|
|
618
|
+
"#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-FUTURE-TAG:1\n" \
|
|
619
|
+
"#EXT-X-TARGETDURATION:10\n#EXTINF:5.0,\nseg.ts\n" \
|
|
620
|
+
"#EXT-X-ENDLIST\n"
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
it 'records unknown tags by default' do
|
|
624
|
+
playlist = M3u8::Playlist.read(content)
|
|
625
|
+
expect(playlist.unknown_tags).to eq(['#EXT-X-FUTURE-TAG:1'])
|
|
626
|
+
expect(playlist.segments.first.segment).to eq('seg.ts')
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
it 'raises in strict mode' do
|
|
630
|
+
expect { M3u8::Playlist.read(content, strict: true) }
|
|
631
|
+
.to raise_error(M3u8::InvalidPlaylistError, /Unsupported tag/)
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
it 'ignores plain comment lines' do
|
|
635
|
+
commented = "#EXTM3U\n# a comment\n#EXT-X-TARGETDURATION:10\n" \
|
|
636
|
+
"#EXTINF:5.0,\nseg.ts\n#EXT-X-ENDLIST\n"
|
|
637
|
+
playlist = M3u8::Playlist.read(commented)
|
|
638
|
+
expect(playlist.unknown_tags).to be_empty
|
|
639
|
+
expect(playlist.segments.first.segment).to eq('seg.ts')
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
it 'does not treat an unknown tag as a segment URI' do
|
|
643
|
+
open_case = "#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXTINF:5.0,\n" \
|
|
644
|
+
"#EXT-X-WAT:1\nseg.ts\n#EXT-X-ENDLIST\n"
|
|
645
|
+
playlist = M3u8::Playlist.read(open_case)
|
|
646
|
+
expect(playlist.segments.first.segment).to eq('seg.ts')
|
|
647
|
+
expect(playlist.unknown_tags).to eq(['#EXT-X-WAT:1'])
|
|
648
|
+
end
|
|
649
|
+
end
|
|
615
650
|
end
|
|
616
651
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'json'
|
|
4
5
|
|
|
5
6
|
describe 'Round-trip serialization' do
|
|
6
7
|
def read_fixture(name)
|
|
@@ -149,4 +150,113 @@ describe 'Round-trip serialization' do
|
|
|
149
150
|
expect(item.data_id).to eq('com.example.lyrics')
|
|
150
151
|
end
|
|
151
152
|
end
|
|
153
|
+
|
|
154
|
+
# Canonical output (the result of rendering a parsed playlist) must be a
|
|
155
|
+
# fixed point of parse-then-render, regardless of whether the original
|
|
156
|
+
# input was canonical. This guards the reader/writer against asymmetries.
|
|
157
|
+
context 'idempotent canonical output (all fixtures)' do
|
|
158
|
+
Dir['spec/fixtures/*.m3u8'].each do |path|
|
|
159
|
+
fixture = File.basename(path)
|
|
160
|
+
|
|
161
|
+
it "produces stable canonical output for #{fixture}" do
|
|
162
|
+
canonical = parse(read_fixture(fixture)).to_s
|
|
163
|
+
expect(parse(canonical).to_s).to eq(canonical)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
context 'idempotent canonical output (generated playlists)' do
|
|
169
|
+
def random_time(rng)
|
|
170
|
+
Time.at(rng.rand(1_500_000_000..1_700_000_000)).utc
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def random_byterange(rng)
|
|
174
|
+
M3u8::ByteRange.new(length: rng.rand(200..900), start: rng.rand(0..50))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def build_segment(rng, index)
|
|
178
|
+
segment = M3u8::SegmentItem.new(
|
|
179
|
+
duration: ((rng.rand * 12) + 0.5).round(3),
|
|
180
|
+
segment: "segment#{index}.ts"
|
|
181
|
+
)
|
|
182
|
+
segment.byterange = random_byterange(rng) if rng.rand < 0.4
|
|
183
|
+
segment.program_date_time = random_time(rng) if rng.rand < 0.3
|
|
184
|
+
segment
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def build_segments(rng)
|
|
188
|
+
[].tap do |items|
|
|
189
|
+
rng.rand(1..5).times do |index|
|
|
190
|
+
items << M3u8::DiscontinuityItem.new if rng.rand < 0.2
|
|
191
|
+
items << build_segment(rng, index)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def random_media_playlist(rng)
|
|
197
|
+
segments = build_segments(rng)
|
|
198
|
+
target = segments.grep(M3u8::SegmentItem)
|
|
199
|
+
.map { |s| s.duration.round }.max
|
|
200
|
+
playlist = M3u8::Playlist.new(version: rng.rand(3..7),
|
|
201
|
+
target: target, sequence: rng.rand(0..9))
|
|
202
|
+
playlist.type = %w[VOD EVENT].sample(random: rng) if rng.rand < 0.5
|
|
203
|
+
playlist.independent_segments = true if rng.rand < 0.5
|
|
204
|
+
playlist.items.concat(segments)
|
|
205
|
+
key = M3u8::KeyItem.new(method: 'AES-128', uri: 'key.bin')
|
|
206
|
+
playlist.items.unshift(key) if rng.rand < 0.3
|
|
207
|
+
playlist
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def build_variant(rng, index)
|
|
211
|
+
M3u8::PlaylistItem.new(uri: "variant#{index}.m3u8",
|
|
212
|
+
bandwidth: rng.rand(100_000..9_000_000),
|
|
213
|
+
width: 1920, height: 1080,
|
|
214
|
+
codecs: 'avc1.640028,mp4a.40.2')
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def random_master_playlist(rng)
|
|
218
|
+
playlist = M3u8::Playlist.new(version: rng.rand(4..7))
|
|
219
|
+
playlist.independent_segments = true if rng.rand < 0.5
|
|
220
|
+
rng.rand(1..4).times do |index|
|
|
221
|
+
playlist.items << build_variant(rng, index)
|
|
222
|
+
end
|
|
223
|
+
rendition = M3u8::MediaItem.new(type: 'AUDIO', group_id: 'audio',
|
|
224
|
+
name: 'English', uri: 'audio.m3u8')
|
|
225
|
+
playlist.items << rendition if rng.rand < 0.6
|
|
226
|
+
playlist
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it 'produces stable canonical output across generated playlists' do
|
|
230
|
+
rng = Random.new(90_125)
|
|
231
|
+
100.times do |iteration|
|
|
232
|
+
playlist = if iteration.even?
|
|
233
|
+
random_media_playlist(rng)
|
|
234
|
+
else
|
|
235
|
+
random_master_playlist(rng)
|
|
236
|
+
end
|
|
237
|
+
canonical = parse(playlist.to_s).to_s
|
|
238
|
+
message = "drift on iteration #{iteration}:\n#{canonical}"
|
|
239
|
+
expect(parse(canonical).to_s).to eq(canonical), message
|
|
240
|
+
rebuilt = M3u8::Playlist.from_h(parse(canonical).to_h)
|
|
241
|
+
expect(rebuilt.to_s).to eq(canonical), message
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# A playlist rebuilt from its Hash (or its JSON) must render identically
|
|
247
|
+
# to the canonical output, exercising Playlist.from_h across every item.
|
|
248
|
+
context 'Hash and JSON round-trip (all fixtures)' do
|
|
249
|
+
Dir['spec/fixtures/*.m3u8'].each do |path|
|
|
250
|
+
fixture = File.basename(path)
|
|
251
|
+
|
|
252
|
+
it "rebuilds #{fixture} from its Hash and JSON" do
|
|
253
|
+
canonical = parse(read_fixture(fixture)).to_s
|
|
254
|
+
playlist = parse(canonical)
|
|
255
|
+
from_hash = M3u8::Playlist.from_h(playlist.to_h)
|
|
256
|
+
expect(from_hash.to_s).to eq(canonical)
|
|
257
|
+
from_json = M3u8::Playlist.from_h(JSON.parse(playlist.to_json))
|
|
258
|
+
expect(from_json.to_s).to eq(canonical)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
152
262
|
end
|
|
@@ -89,4 +89,15 @@ describe M3u8::SegmentItem do
|
|
|
89
89
|
'segment.aac'
|
|
90
90
|
expect(output).to eq expected
|
|
91
91
|
end
|
|
92
|
+
|
|
93
|
+
it 'converts very small durations to floating point' do
|
|
94
|
+
time = Time.iso8601('2020-11-25T20:27:00Z')
|
|
95
|
+
hash = { duration: 0.000001, segment: 'test.ts', program_date_time: time }
|
|
96
|
+
item = M3u8::SegmentItem.new(hash)
|
|
97
|
+
output = item.to_s
|
|
98
|
+
expected = "#EXTINF:0.000001,\n" \
|
|
99
|
+
"#EXT-X-PROGRAM-DATE-TIME:2020-11-25T20:27:00Z\n" \
|
|
100
|
+
'test.ts'
|
|
101
|
+
expect(output).to eq expected
|
|
102
|
+
end
|
|
92
103
|
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
describe M3u8::Serializable do
|
|
7
|
+
describe '.serialize' do
|
|
8
|
+
it 'converts a Time to an iso8601 string' do
|
|
9
|
+
time = Time.utc(2020, 1, 2, 3, 4, 5)
|
|
10
|
+
expect(described_class.serialize(time)).to eq('2020-01-02T03:04:05Z')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'converts a TimeItem to its time value' do
|
|
14
|
+
item = M3u8::TimeItem.new(time: Time.utc(2020, 1, 2, 3, 4, 5))
|
|
15
|
+
expect(described_class.serialize(item)).to eq('2020-01-02T03:04:05Z')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'converts a BigDecimal to a Float' do
|
|
19
|
+
expect(described_class.serialize(BigDecimal('29.97'))).to eq(29.97)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'converts a nested Serializable to a Hash' do
|
|
23
|
+
range = M3u8::ByteRange.new(length: 100, start: 5)
|
|
24
|
+
expect(described_class.serialize(range)).to eq(length: 100, start: 5)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'recursively serializes Hash values' do
|
|
28
|
+
result = described_class.serialize('X-A' => BigDecimal('1.5'))
|
|
29
|
+
expect(result).to eq('X-A' => 1.5)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'returns scalar values unchanged' do
|
|
33
|
+
expect(described_class.serialize('plain')).to eq('plain')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe '#to_h' do
|
|
38
|
+
it 'maps instance variables to symbol keys' do
|
|
39
|
+
item = M3u8::ByteRange.new(length: 300, start: 12)
|
|
40
|
+
expect(item.to_h).to eq(length: 300, start: 12)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'returns an empty Hash when there are no attributes' do
|
|
44
|
+
expect(M3u8::GapItem.new.to_h).to eq({})
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'serializes a segment program date time to a string' do
|
|
48
|
+
segment = M3u8::SegmentItem.new(duration: 5.0, segment: 'a.ts')
|
|
49
|
+
segment.program_date_time =
|
|
50
|
+
M3u8::TimeItem.new(time: Time.utc(2020, 1, 1))
|
|
51
|
+
expect(segment.to_h[:program_date_time]).to eq('2020-01-01T00:00:00Z')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'serializes a byte range to a nested Hash' do
|
|
55
|
+
segment = M3u8::SegmentItem.new(duration: 5.0, segment: 'a.ts',
|
|
56
|
+
byterange: { length: 50, start: 2 })
|
|
57
|
+
expect(segment.to_h[:byterange]).to eq(length: 50, start: 2)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'serializes client attributes as a Hash' do
|
|
61
|
+
item = M3u8::DateRangeItem.new(id: 'x',
|
|
62
|
+
client_attributes: { 'X-A' => 'b' })
|
|
63
|
+
expect(item.to_h[:client_attributes]).to eq('X-A' => 'b')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'omits raw HLS attribute names for parsed keys' do
|
|
67
|
+
key = M3u8::KeyItem.parse(
|
|
68
|
+
'#EXT-X-KEY:METHOD=AES-128,URI="k.key",IV=0x1234'
|
|
69
|
+
)
|
|
70
|
+
expect(key.to_h.keys).to contain_exactly(
|
|
71
|
+
:method, :uri, :iv, :key_format, :key_format_versions
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
describe 'M3u8::Playlist#to_h' do
|
|
77
|
+
def read_fixture(name)
|
|
78
|
+
M3u8::Playlist.read(File.read("spec/fixtures/#{name}"))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'includes top-level attributes and typed items for a master' do
|
|
82
|
+
hash = read_fixture('master.m3u8').to_h
|
|
83
|
+
expect(hash[:master]).to be(true)
|
|
84
|
+
expect(hash[:part_inf]).to be_nil
|
|
85
|
+
expect(hash[:items]).to all(include(:item_type))
|
|
86
|
+
types = hash[:items].map { |item| item[:item_type] }
|
|
87
|
+
expect(types).to include('PlaylistItem')
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'serializes part_inf and server_control for LL-HLS' do
|
|
91
|
+
hash = read_fixture('ll_hls_playlist.m3u8').to_h
|
|
92
|
+
expect(hash[:master]).to be(false)
|
|
93
|
+
expect(hash[:server_control][:can_skip_until]).to eq(24.0)
|
|
94
|
+
expect(hash[:part_inf][:part_target]).to eq(0.5)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
describe '#as_json and #to_json' do
|
|
99
|
+
it 'as_json returns the same Hash as to_h' do
|
|
100
|
+
item = M3u8::ByteRange.new(length: 10, start: 2)
|
|
101
|
+
expect(item.as_json).to eq(item.to_h)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'to_json renders the attributes as JSON' do
|
|
105
|
+
item = M3u8::ByteRange.new(length: 10, start: 2)
|
|
106
|
+
expect(JSON.parse(item.to_json)).to eq('length' => 10, 'start' => 2)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe 'M3u8::Playlist.from_h' do
|
|
111
|
+
def read_fixture(name)
|
|
112
|
+
M3u8::Playlist.read(File.read("spec/fixtures/#{name}"))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'round-trips a playlist through to_h' do
|
|
116
|
+
original = read_fixture('encrypted.m3u8')
|
|
117
|
+
rebuilt = M3u8::Playlist.from_h(original.to_h)
|
|
118
|
+
expect(rebuilt.to_s).to eq(original.to_s)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'round-trips a playlist through JSON' do
|
|
122
|
+
original = read_fixture('master.m3u8')
|
|
123
|
+
rebuilt = M3u8::Playlist.from_h(JSON.parse(original.to_json))
|
|
124
|
+
expect(rebuilt.to_s).to eq(original.to_s)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'raises for an unknown item type' do
|
|
128
|
+
expect do
|
|
129
|
+
M3u8::Playlist.from_h(items: [{ item_type: 'Bogus' }])
|
|
130
|
+
end.to raise_error(M3u8::InvalidPlaylistError, /Bogus/)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'open3'
|
|
4
5
|
|
|
5
6
|
describe M3u8::TimeItem do
|
|
7
|
+
it 'parses program date time after only requiring m3u8' do
|
|
8
|
+
lib = File.expand_path('../../../lib', __dir__)
|
|
9
|
+
script = <<~'RUBY'
|
|
10
|
+
require 'm3u8'
|
|
11
|
+
lines = ['#EXTM3U',
|
|
12
|
+
'#EXT-X-PROGRAM-DATE-TIME:2010-02-19T14:54:23Z']
|
|
13
|
+
M3u8::Playlist.read(lines.join("\n") + "\n")
|
|
14
|
+
RUBY
|
|
15
|
+
output, status = Open3.capture2e('ruby', '-I', lib, '-e', script)
|
|
16
|
+
expect(status).to be_success, output
|
|
17
|
+
end
|
|
18
|
+
|
|
6
19
|
it 'should provide m3u8 format representation' do
|
|
7
20
|
options = { time: '2010-02-19T14:54:23.031' }
|
|
8
21
|
item = M3u8::TimeItem.new(options)
|