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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +89 -0
  4. data/lib/m3u8/attribute_formatter.rb +17 -0
  5. data/lib/m3u8/bitrate_item.rb +2 -0
  6. data/lib/m3u8/byte_range.rb +2 -0
  7. data/lib/m3u8/cli/diff_command.rb +61 -0
  8. data/lib/m3u8/cli/inspect_command.rb +9 -1
  9. data/lib/m3u8/cli/validate_command.rb +16 -0
  10. data/lib/m3u8/cli.rb +45 -11
  11. data/lib/m3u8/content_steering_item.rb +1 -0
  12. data/lib/m3u8/date_range_item.rb +6 -5
  13. data/lib/m3u8/define_item.rb +1 -0
  14. data/lib/m3u8/discontinuity_item.rb +2 -0
  15. data/lib/m3u8/encryptable.rb +10 -0
  16. data/lib/m3u8/gap_item.rb +2 -0
  17. data/lib/m3u8/key_item.rb +1 -8
  18. data/lib/m3u8/map_item.rb +1 -0
  19. data/lib/m3u8/media_item.rb +1 -0
  20. data/lib/m3u8/part_inf_item.rb +1 -0
  21. data/lib/m3u8/part_item.rb +2 -1
  22. data/lib/m3u8/playback_start.rb +1 -0
  23. data/lib/m3u8/playlist.rb +236 -2
  24. data/lib/m3u8/playlist_item.rb +1 -0
  25. data/lib/m3u8/preload_hint_item.rb +1 -0
  26. data/lib/m3u8/reader.rb +15 -2
  27. data/lib/m3u8/rendition_report_item.rb +1 -0
  28. data/lib/m3u8/segment_item.rb +3 -1
  29. data/lib/m3u8/serializable.rb +43 -0
  30. data/lib/m3u8/server_control_item.rb +1 -0
  31. data/lib/m3u8/session_data_item.rb +1 -0
  32. data/lib/m3u8/session_key_item.rb +1 -8
  33. data/lib/m3u8/skip_item.rb +1 -0
  34. data/lib/m3u8/time_item.rb +1 -0
  35. data/lib/m3u8/variable_resolver.rb +83 -0
  36. data/lib/m3u8/version.rb +1 -1
  37. data/lib/m3u8.rb +2 -0
  38. data/m3u8.gemspec +1 -0
  39. data/spec/lib/m3u8/cli/diff_command_spec.rb +49 -0
  40. data/spec/lib/m3u8/cli/inspect_command_spec.rb +12 -0
  41. data/spec/lib/m3u8/cli/validate_command_spec.rb +17 -1
  42. data/spec/lib/m3u8/cli_spec.rb +47 -1
  43. data/spec/lib/m3u8/conformance_spec.rb +123 -0
  44. data/spec/lib/m3u8/date_range_item_spec.rb +20 -0
  45. data/spec/lib/m3u8/part_item_spec.rb +7 -0
  46. data/spec/lib/m3u8/reader_spec.rb +35 -0
  47. data/spec/lib/m3u8/round_trip_spec.rb +110 -0
  48. data/spec/lib/m3u8/segment_item_spec.rb +11 -0
  49. data/spec/lib/m3u8/serializable_spec.rb +133 -0
  50. data/spec/lib/m3u8/time_item_spec.rb +13 -0
  51. data/spec/lib/m3u8/variable_resolver_spec.rb +104 -0
  52. 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.strip).to eq('Valid')
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
@@ -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.strip).to eq('Valid')
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)