m3u8 1.3.1 → 1.5.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 +14 -0
- data/README.md +58 -1
- data/lib/m3u8/cli/validate_command.rb +2 -1
- data/lib/m3u8/playlist.rb +103 -4
- data/lib/m3u8/playlist_item.rb +7 -1
- data/lib/m3u8/reader.rb +1 -1
- data/lib/m3u8/version.rb +1 -1
- data/lib/m3u8/writer.rb +2 -1
- data/spec/lib/m3u8/builder_spec.rb +14 -0
- data/spec/lib/m3u8/cli/validate_command_spec.rb +6 -2
- data/spec/lib/m3u8/playlist_item_spec.rb +6 -0
- data/spec/lib/m3u8/playlist_spec.rb +429 -2
- data/spec/lib/m3u8/reader_spec.rb +14 -0
- data/spec/lib/m3u8/writer_spec.rb +11 -10
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: df37c1a4cc89e0bd918c6137c5bc86682d61638b407697240e76f40acb8983e7
|
|
4
|
+
data.tar.gz: e19fd10c1b3f732057285247f08bb235573fa6434085295b53a76be22f1a53aa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c4ff8a727525925718fcb0ff7fed0b8bb758556d8e262c97ebacaca4fe96550b4494817ce5d8f4a0525d961a274a1905b16af6b5a85b8ac57235254f54392f8a
|
|
7
|
+
data.tar.gz: 19ae07aa8cd202846254983b823973c4b7d0a9d4bde50e5cc8be7e1e4d91182205bec84731f628298f2659085434716120135a074656e1c9c2aa2c1988415b4f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
**1.5.0**
|
|
2
|
+
|
|
3
|
+
* Added `Playlist#freeze` for deep-freezing playlists, items, nested objects, and playlist-level objects. `Playlist.build` and `Playlist.read` now return frozen playlists. `Playlist.new` remains mutable until `freeze` is called explicitly.
|
|
4
|
+
|
|
5
|
+
***
|
|
6
|
+
|
|
7
|
+
**1.4.0**
|
|
8
|
+
|
|
9
|
+
* Added `Playlist#errors` method returning an array of validation error messages. `Playlist#valid?` now delegates to `errors.empty?`. Validates mixed item types, target duration, segment items, playlist items, media items, encryption keys, session keys, session data, and LL-HLS part items.
|
|
10
|
+
* Updated CLI `validate` command to display specific error messages.
|
|
11
|
+
* Updated `Writer` to include specific errors in the exception message.
|
|
12
|
+
|
|
13
|
+
***
|
|
14
|
+
|
|
1
15
|
**1.3.1**
|
|
2
16
|
|
|
3
17
|
* Excluded CLAUDE.md and AGENTS.md from gem package.
|
data/README.md
CHANGED
|
@@ -79,7 +79,8 @@ $ m3u8 validate playlist.m3u8
|
|
|
79
79
|
Valid
|
|
80
80
|
|
|
81
81
|
$ m3u8 validate bad.m3u8
|
|
82
|
-
Invalid
|
|
82
|
+
Invalid
|
|
83
|
+
- Playlist contains both master and media items
|
|
83
84
|
```
|
|
84
85
|
|
|
85
86
|
## Usage (Builder DSL)
|
|
@@ -343,6 +344,62 @@ options = { width: 1920, height: 1080, codecs: 'avc1.66.30,mp4a.40.2',
|
|
|
343
344
|
item = M3u8::PlaylistItem.new(options)
|
|
344
345
|
```
|
|
345
346
|
|
|
347
|
+
## Frozen playlists
|
|
348
|
+
|
|
349
|
+
Playlists returned by `Playlist.build` and `Playlist.read` are frozen (deeply immutable). Items, nested objects, and the items array are all frozen, preventing accidental mutation after construction:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
playlist = M3u8::Playlist.read(File.open('master.m3u8'))
|
|
353
|
+
playlist.frozen? # => true
|
|
354
|
+
playlist.items.frozen? # => true
|
|
355
|
+
playlist.items.first.frozen? # => true
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Playlists created with `Playlist.new` remain mutable. Call `freeze` explicitly when ready:
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
playlist = M3u8::Playlist.new
|
|
362
|
+
playlist.items << M3u8::SegmentItem.new(duration: 10.0, segment: 'test.ts')
|
|
363
|
+
playlist.freeze
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Frozen playlists still support `to_s` and `write` for output.
|
|
367
|
+
|
|
368
|
+
## Validation
|
|
369
|
+
|
|
370
|
+
Check whether a playlist is valid and inspect specific errors:
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
playlist.valid?
|
|
374
|
+
# => true
|
|
375
|
+
|
|
376
|
+
playlist.errors
|
|
377
|
+
# => []
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
When a playlist has issues, `errors` returns descriptive messages:
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
playlist.valid?
|
|
384
|
+
# => false
|
|
385
|
+
|
|
386
|
+
playlist.errors
|
|
387
|
+
# => ["Playlist contains both master and media items"]
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
The following validations are performed:
|
|
391
|
+
|
|
392
|
+
* Mixed item types (both master and media items in one playlist)
|
|
393
|
+
* Target duration less than any segment's rounded duration
|
|
394
|
+
* Segment items missing a URI or having a negative duration
|
|
395
|
+
* Playlist items missing a URI or valid bandwidth
|
|
396
|
+
* Media items missing type, group ID, or name
|
|
397
|
+
* Key and session key items missing a URI when method is not NONE
|
|
398
|
+
* Session data items missing data ID, or having both/neither value and URI
|
|
399
|
+
* Part items missing a URI or duration
|
|
400
|
+
|
|
401
|
+
`valid?` delegates to `errors.empty?` and both are recomputed on each call.
|
|
402
|
+
|
|
346
403
|
## Usage (parsing playlists)
|
|
347
404
|
|
|
348
405
|
```ruby
|
data/lib/m3u8/playlist.rb
CHANGED
|
@@ -22,7 +22,7 @@ module M3u8
|
|
|
22
22
|
else
|
|
23
23
|
builder.instance_eval(&block)
|
|
24
24
|
end
|
|
25
|
-
playlist
|
|
25
|
+
playlist.freeze
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def self.codecs(options = {})
|
|
@@ -53,16 +53,36 @@ module M3u8
|
|
|
53
53
|
playlist_size.positive?
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
def freeze
|
|
57
|
+
items.each { |item| freeze_item(item) }
|
|
58
|
+
items.freeze
|
|
59
|
+
part_inf&.freeze
|
|
60
|
+
server_control&.freeze
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
|
|
56
64
|
def to_s
|
|
57
65
|
output = StringIO.open
|
|
58
66
|
write(output)
|
|
59
67
|
output.string
|
|
60
68
|
end
|
|
61
69
|
|
|
62
|
-
def
|
|
63
|
-
|
|
70
|
+
def errors
|
|
71
|
+
[].tap do |errors|
|
|
72
|
+
validate_mixed_items(errors)
|
|
73
|
+
validate_target_duration(errors)
|
|
74
|
+
validate_segment_items(errors)
|
|
75
|
+
validate_playlist_items(errors)
|
|
76
|
+
validate_media_items(errors)
|
|
77
|
+
validate_key_items(errors)
|
|
78
|
+
validate_session_key_items(errors)
|
|
79
|
+
validate_session_data_items(errors)
|
|
80
|
+
validate_part_items(errors)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
64
83
|
|
|
65
|
-
|
|
84
|
+
def valid?
|
|
85
|
+
errors.empty?
|
|
66
86
|
end
|
|
67
87
|
|
|
68
88
|
def segments
|
|
@@ -107,6 +127,13 @@ module M3u8
|
|
|
107
127
|
|
|
108
128
|
private
|
|
109
129
|
|
|
130
|
+
def freeze_item(item)
|
|
131
|
+
item.byterange&.freeze if item.respond_to?(:byterange)
|
|
132
|
+
item.program_date_time&.freeze if item.respond_to?(:program_date_time)
|
|
133
|
+
item.client_attributes&.freeze if item.respond_to?(:client_attributes)
|
|
134
|
+
item.freeze
|
|
135
|
+
end
|
|
136
|
+
|
|
110
137
|
def assign_options(options)
|
|
111
138
|
options = defaults.merge(options)
|
|
112
139
|
|
|
@@ -134,6 +161,78 @@ module M3u8
|
|
|
134
161
|
}
|
|
135
162
|
end
|
|
136
163
|
|
|
164
|
+
def validate_part_items(errors)
|
|
165
|
+
parts.each do |item|
|
|
166
|
+
errors << 'Part item requires a URI' if item.uri.nil?
|
|
167
|
+
errors << 'Part item requires a duration' if item.duration.nil?
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def validate_session_data_items(errors)
|
|
172
|
+
session_data.each do |item|
|
|
173
|
+
errors << 'Session data item requires a data ID' if item.data_id.nil?
|
|
174
|
+
if !item.value.nil? && !item.uri.nil?
|
|
175
|
+
errors << 'Session data item cannot have both value and URI'
|
|
176
|
+
elsif item.value.nil? && item.uri.nil?
|
|
177
|
+
errors << 'Session data item requires a value or URI'
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def validate_key_items(errors)
|
|
183
|
+
keys.each do |item|
|
|
184
|
+
next if item.method == 'NONE'
|
|
185
|
+
|
|
186
|
+
errors << 'Key item requires a URI when method is not NONE' if item.uri.nil?
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def validate_session_key_items(errors)
|
|
191
|
+
session_keys.each do |item|
|
|
192
|
+
next if item.method == 'NONE'
|
|
193
|
+
|
|
194
|
+
errors << 'Session key item requires a URI when method is not NONE' if item.uri.nil?
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def validate_playlist_items(errors)
|
|
199
|
+
playlists.each do |item|
|
|
200
|
+
errors << 'Playlist item requires a bandwidth' unless item.bandwidth&.positive?
|
|
201
|
+
errors << 'Playlist item requires a URI' if item.uri.nil?
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def validate_media_items(errors)
|
|
206
|
+
media_items.each do |item|
|
|
207
|
+
errors << 'Media item requires a type' if item.type.nil?
|
|
208
|
+
errors << 'Media item requires a group ID' if item.group_id.nil?
|
|
209
|
+
errors << 'Media item requires a name' if item.name.nil?
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def validate_segment_items(errors)
|
|
214
|
+
segments.each do |segment|
|
|
215
|
+
errors << 'Segment item requires a segment URI' if segment.segment.nil?
|
|
216
|
+
errors << 'Segment item has negative duration' if segment.duration&.negative?
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def validate_target_duration(errors)
|
|
221
|
+
return if master?
|
|
222
|
+
|
|
223
|
+
max = segments.filter_map { |s| s.duration&.round }.max
|
|
224
|
+
return if max.nil? || target >= max
|
|
225
|
+
|
|
226
|
+
errors << "Target duration #{target} is less than " \
|
|
227
|
+
"segment duration of #{max}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def validate_mixed_items(errors)
|
|
231
|
+
return unless playlist_size.positive? && segment_size.positive?
|
|
232
|
+
|
|
233
|
+
errors << 'Playlist contains both master and media items'
|
|
234
|
+
end
|
|
235
|
+
|
|
137
236
|
def playlist_size
|
|
138
237
|
playlists.size
|
|
139
238
|
end
|
data/lib/m3u8/playlist_item.rb
CHANGED
|
@@ -69,7 +69,7 @@ module M3u8
|
|
|
69
69
|
codecs: attributes['CODECS'],
|
|
70
70
|
width: resolution[:width],
|
|
71
71
|
height: resolution[:height],
|
|
72
|
-
bandwidth: attributes['BANDWIDTH']
|
|
72
|
+
bandwidth: parse_bandwidth(attributes['BANDWIDTH']),
|
|
73
73
|
average_bandwidth:
|
|
74
74
|
parse_average_bandwidth(attributes['AVERAGE-BANDWIDTH']),
|
|
75
75
|
frame_rate: parse_frame_rate(attributes['FRAME-RATE']),
|
|
@@ -90,6 +90,12 @@ module M3u8
|
|
|
90
90
|
value&.to_i
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
def parse_bandwidth(value)
|
|
94
|
+
return if value.nil?
|
|
95
|
+
|
|
96
|
+
value.to_i
|
|
97
|
+
end
|
|
98
|
+
|
|
93
99
|
def parse_resolution(resolution)
|
|
94
100
|
return { width: nil, height: nil } if resolution.nil?
|
|
95
101
|
|
data/lib/m3u8/reader.rb
CHANGED
data/lib/m3u8/version.rb
CHANGED
data/lib/m3u8/writer.rb
CHANGED
|
@@ -191,6 +191,20 @@ describe M3u8::Builder do
|
|
|
191
191
|
expect(playlist.target).to eq(12)
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
+
it 'returns a frozen playlist' do
|
|
195
|
+
playlist = M3u8::Playlist.build do
|
|
196
|
+
segment duration: 10.0, segment: 'test.ts'
|
|
197
|
+
end
|
|
198
|
+
expect(playlist).to be_frozen
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'returns a frozen playlist with yielded form' do
|
|
202
|
+
playlist = M3u8::Playlist.build do |b|
|
|
203
|
+
b.segment duration: 10.0, segment: 'test.ts'
|
|
204
|
+
end
|
|
205
|
+
expect(playlist).to be_frozen
|
|
206
|
+
end
|
|
207
|
+
|
|
194
208
|
it 'supports yielded builder form' do
|
|
195
209
|
files = %w[seg1.ts seg2.ts]
|
|
196
210
|
playlist = M3u8::Playlist.build(version: 4) do |b|
|
|
@@ -18,7 +18,7 @@ describe M3u8::CLI::ValidateCommand do
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
context 'when playlist is invalid' do
|
|
21
|
-
it 'prints Invalid and returns 1' do
|
|
21
|
+
it 'prints Invalid with specific errors and returns 1' do
|
|
22
22
|
playlist = M3u8::Playlist.new
|
|
23
23
|
playlist.items << M3u8::PlaylistItem.new(
|
|
24
24
|
bandwidth: 540, uri: 'test.url'
|
|
@@ -28,7 +28,11 @@ describe M3u8::CLI::ValidateCommand do
|
|
|
28
28
|
)
|
|
29
29
|
code = described_class.new(playlist, stdout).run
|
|
30
30
|
expect(code).to eq(1)
|
|
31
|
-
|
|
31
|
+
lines = stdout.string.split("\n")
|
|
32
|
+
expect(lines[0]).to eq('Invalid')
|
|
33
|
+
expect(lines[1]).to eq(
|
|
34
|
+
' - Playlist contains both master and media items'
|
|
35
|
+
)
|
|
32
36
|
end
|
|
33
37
|
end
|
|
34
38
|
end
|
|
@@ -94,6 +94,12 @@ describe M3u8::PlaylistItem do
|
|
|
94
94
|
expect(item.supplemental_codecs).to eq('dvh1.05.06/db4g')
|
|
95
95
|
expect(item.req_video_layout).to eq('CH-MONO')
|
|
96
96
|
end
|
|
97
|
+
|
|
98
|
+
it 'keeps bandwidth nil when BANDWIDTH is missing' do
|
|
99
|
+
input = '#EXT-X-STREAM-INF:CODECS="avc",URI="test.url"'
|
|
100
|
+
item = M3u8::PlaylistItem.parse(input)
|
|
101
|
+
expect(item.bandwidth).to be_nil
|
|
102
|
+
end
|
|
97
103
|
end
|
|
98
104
|
|
|
99
105
|
describe '#to_s' do
|
|
@@ -182,6 +182,7 @@ describe M3u8::Playlist do
|
|
|
182
182
|
end
|
|
183
183
|
|
|
184
184
|
it 'returns media playlist text' do
|
|
185
|
+
playlist = described_class.new(target: 12)
|
|
185
186
|
options = { duration: 11.344644, segment: '1080-7mbps00000.ts' }
|
|
186
187
|
item = M3u8::SegmentItem.new(options)
|
|
187
188
|
playlist.items << item
|
|
@@ -192,7 +193,7 @@ describe M3u8::Playlist do
|
|
|
192
193
|
|
|
193
194
|
expected = "#EXTM3U\n" \
|
|
194
195
|
"#EXT-X-MEDIA-SEQUENCE:0\n" \
|
|
195
|
-
"#EXT-X-TARGETDURATION:
|
|
196
|
+
"#EXT-X-TARGETDURATION:12\n" \
|
|
196
197
|
"#EXTINF:11.344644,\n" \
|
|
197
198
|
"1080-7mbps00000.ts\n" \
|
|
198
199
|
"#EXTINF:11.261233,\n" \
|
|
@@ -237,6 +238,329 @@ describe M3u8::Playlist do
|
|
|
237
238
|
end
|
|
238
239
|
end
|
|
239
240
|
|
|
241
|
+
describe '#errors' do
|
|
242
|
+
context 'when playlist is empty' do
|
|
243
|
+
it 'returns no errors' do
|
|
244
|
+
expect(playlist.errors).to be_empty
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
context 'when playlist has only master items' do
|
|
249
|
+
it 'returns no errors' do
|
|
250
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
251
|
+
bandwidth: 540, uri: 'test.url'
|
|
252
|
+
)
|
|
253
|
+
expect(playlist.errors).to be_empty
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
context 'when playlist has only media items' do
|
|
258
|
+
it 'returns no errors' do
|
|
259
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
260
|
+
duration: 10.0, segment: 'test.ts'
|
|
261
|
+
)
|
|
262
|
+
expect(playlist.errors).to be_empty
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
context 'when segment duration exceeds target duration' do
|
|
267
|
+
it 'returns target duration error' do
|
|
268
|
+
playlist = described_class.new(target: 10)
|
|
269
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
270
|
+
duration: 12.1, segment: 'test.ts'
|
|
271
|
+
)
|
|
272
|
+
expect(playlist.errors).to include(
|
|
273
|
+
'Target duration 10 is less than segment duration of 12'
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
context 'when segment duration rounds to target' do
|
|
279
|
+
it 'returns no errors' do
|
|
280
|
+
playlist = described_class.new(target: 11)
|
|
281
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
282
|
+
duration: 10.5, segment: 'test.ts'
|
|
283
|
+
)
|
|
284
|
+
expect(playlist.errors).to be_empty
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
context 'when playlist is master' do
|
|
289
|
+
it 'skips target duration check' do
|
|
290
|
+
playlist = described_class.new(master: true)
|
|
291
|
+
expect(playlist.errors).to be_empty
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
context 'when segment has no URI' do
|
|
296
|
+
it 'returns missing segment error' do
|
|
297
|
+
playlist.items << M3u8::SegmentItem.new(duration: 10.0)
|
|
298
|
+
expect(playlist.errors).to include(
|
|
299
|
+
'Segment item requires a segment URI'
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
context 'when segment has negative duration' do
|
|
305
|
+
it 'returns negative duration error' do
|
|
306
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
307
|
+
duration: -1.0, segment: 'test.ts'
|
|
308
|
+
)
|
|
309
|
+
expect(playlist.errors).to include(
|
|
310
|
+
'Segment item has negative duration'
|
|
311
|
+
)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
context 'when segment has zero duration' do
|
|
316
|
+
it 'returns no errors' do
|
|
317
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
318
|
+
duration: 0.0, segment: 'test.ts'
|
|
319
|
+
)
|
|
320
|
+
expect(playlist.errors).to be_empty
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
context 'when multiple segments are invalid' do
|
|
325
|
+
it 'accumulates errors' do
|
|
326
|
+
playlist.items << M3u8::SegmentItem.new(duration: 10.0)
|
|
327
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
328
|
+
duration: -1.0, segment: 'test.ts'
|
|
329
|
+
)
|
|
330
|
+
errors = playlist.errors
|
|
331
|
+
expect(errors).to include(
|
|
332
|
+
'Segment item requires a segment URI'
|
|
333
|
+
)
|
|
334
|
+
expect(errors).to include(
|
|
335
|
+
'Segment item has negative duration'
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
context 'when playlist item has no bandwidth' do
|
|
341
|
+
it 'returns missing bandwidth error' do
|
|
342
|
+
playlist.items << M3u8::PlaylistItem.new(uri: 'test.url')
|
|
343
|
+
expect(playlist.errors).to include(
|
|
344
|
+
'Playlist item requires a bandwidth'
|
|
345
|
+
)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
context 'when playlist item has no URI and is not iframe' do
|
|
350
|
+
it 'returns missing URI error' do
|
|
351
|
+
playlist.items << M3u8::PlaylistItem.new(bandwidth: 540)
|
|
352
|
+
expect(playlist.errors).to include(
|
|
353
|
+
'Playlist item requires a URI'
|
|
354
|
+
)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
context 'when playlist item has zero bandwidth' do
|
|
359
|
+
it 'returns missing bandwidth error' do
|
|
360
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
361
|
+
bandwidth: 0, uri: 'test.url'
|
|
362
|
+
)
|
|
363
|
+
expect(playlist.errors).to include(
|
|
364
|
+
'Playlist item requires a bandwidth'
|
|
365
|
+
)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
context 'when iframe playlist item has no URI' do
|
|
370
|
+
it 'returns missing URI error' do
|
|
371
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
372
|
+
bandwidth: 540, iframe: true
|
|
373
|
+
)
|
|
374
|
+
expect(playlist.errors).to include(
|
|
375
|
+
'Playlist item requires a URI'
|
|
376
|
+
)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
context 'when media item is missing type' do
|
|
381
|
+
it 'returns missing type error' do
|
|
382
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
383
|
+
bandwidth: 540, uri: 'test.url'
|
|
384
|
+
)
|
|
385
|
+
playlist.items << M3u8::MediaItem.new(
|
|
386
|
+
group_id: 'audio', name: 'English'
|
|
387
|
+
)
|
|
388
|
+
expect(playlist.errors).to include(
|
|
389
|
+
'Media item requires a type'
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
context 'when media item is missing group_id' do
|
|
395
|
+
it 'returns missing group_id error' do
|
|
396
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
397
|
+
bandwidth: 540, uri: 'test.url'
|
|
398
|
+
)
|
|
399
|
+
playlist.items << M3u8::MediaItem.new(
|
|
400
|
+
type: 'AUDIO', name: 'English'
|
|
401
|
+
)
|
|
402
|
+
expect(playlist.errors).to include(
|
|
403
|
+
'Media item requires a group ID'
|
|
404
|
+
)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
context 'when media item is missing name' do
|
|
409
|
+
it 'returns missing name error' do
|
|
410
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
411
|
+
bandwidth: 540, uri: 'test.url'
|
|
412
|
+
)
|
|
413
|
+
playlist.items << M3u8::MediaItem.new(
|
|
414
|
+
type: 'AUDIO', group_id: 'audio'
|
|
415
|
+
)
|
|
416
|
+
expect(playlist.errors).to include(
|
|
417
|
+
'Media item requires a name'
|
|
418
|
+
)
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
context 'when key item has method but no URI' do
|
|
423
|
+
it 'returns missing URI error' do
|
|
424
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
425
|
+
duration: 10.0, segment: 'test.ts'
|
|
426
|
+
)
|
|
427
|
+
playlist.items << M3u8::KeyItem.new(method: 'AES-128')
|
|
428
|
+
expect(playlist.errors).to include(
|
|
429
|
+
'Key item requires a URI when method is not NONE'
|
|
430
|
+
)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
context 'when key item method is NONE' do
|
|
435
|
+
it 'returns no errors' do
|
|
436
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
437
|
+
duration: 10.0, segment: 'test.ts'
|
|
438
|
+
)
|
|
439
|
+
playlist.items << M3u8::KeyItem.new(method: 'NONE')
|
|
440
|
+
expect(playlist.errors).to be_empty
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
context 'when session key item has method but no URI' do
|
|
445
|
+
it 'returns missing URI error' do
|
|
446
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
447
|
+
bandwidth: 540, uri: 'test.url'
|
|
448
|
+
)
|
|
449
|
+
playlist.items << M3u8::SessionKeyItem.new(
|
|
450
|
+
method: 'AES-128'
|
|
451
|
+
)
|
|
452
|
+
expect(playlist.errors).to include(
|
|
453
|
+
'Session key item requires a URI when method is not NONE'
|
|
454
|
+
)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
context 'when session data item has no data_id' do
|
|
459
|
+
it 'returns missing data_id error' do
|
|
460
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
461
|
+
bandwidth: 540, uri: 'test.url'
|
|
462
|
+
)
|
|
463
|
+
playlist.items << M3u8::SessionDataItem.new(
|
|
464
|
+
value: 'Test'
|
|
465
|
+
)
|
|
466
|
+
expect(playlist.errors).to include(
|
|
467
|
+
'Session data item requires a data ID'
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
context 'when session data item has both value and uri' do
|
|
473
|
+
it 'returns conflict error' do
|
|
474
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
475
|
+
bandwidth: 540, uri: 'test.url'
|
|
476
|
+
)
|
|
477
|
+
playlist.items << M3u8::SessionDataItem.new(
|
|
478
|
+
data_id: 'com.test', value: 'Test',
|
|
479
|
+
uri: 'http://test'
|
|
480
|
+
)
|
|
481
|
+
expect(playlist.errors).to include(
|
|
482
|
+
'Session data item cannot have both value and URI'
|
|
483
|
+
)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
context 'when session data item has neither value nor uri' do
|
|
488
|
+
it 'returns missing value error' do
|
|
489
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
490
|
+
bandwidth: 540, uri: 'test.url'
|
|
491
|
+
)
|
|
492
|
+
playlist.items << M3u8::SessionDataItem.new(
|
|
493
|
+
data_id: 'com.test'
|
|
494
|
+
)
|
|
495
|
+
expect(playlist.errors).to include(
|
|
496
|
+
'Session data item requires a value or URI'
|
|
497
|
+
)
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
context 'when session data item has only value' do
|
|
502
|
+
it 'returns no session data errors' do
|
|
503
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
504
|
+
bandwidth: 540, uri: 'test.url'
|
|
505
|
+
)
|
|
506
|
+
playlist.items << M3u8::SessionDataItem.new(
|
|
507
|
+
data_id: 'com.test', value: 'Test'
|
|
508
|
+
)
|
|
509
|
+
expect(playlist.errors).to be_empty
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
context 'when part item has no URI' do
|
|
514
|
+
it 'returns missing URI error' do
|
|
515
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
516
|
+
duration: 4.0, segment: 'seg.mp4'
|
|
517
|
+
)
|
|
518
|
+
playlist.items << M3u8::PartItem.new(duration: 0.5)
|
|
519
|
+
expect(playlist.errors).to include(
|
|
520
|
+
'Part item requires a URI'
|
|
521
|
+
)
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
context 'when part item has no duration' do
|
|
526
|
+
it 'returns missing duration error' do
|
|
527
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
528
|
+
duration: 4.0, segment: 'seg.mp4'
|
|
529
|
+
)
|
|
530
|
+
playlist.items << M3u8::PartItem.new(uri: 'seg.0.mp4')
|
|
531
|
+
expect(playlist.errors).to include(
|
|
532
|
+
'Part item requires a duration'
|
|
533
|
+
)
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
context 'when part item is valid' do
|
|
538
|
+
it 'returns no part errors' do
|
|
539
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
540
|
+
duration: 4.0, segment: 'seg.mp4'
|
|
541
|
+
)
|
|
542
|
+
playlist.items << M3u8::PartItem.new(
|
|
543
|
+
duration: 0.5, uri: 'seg.0.mp4'
|
|
544
|
+
)
|
|
545
|
+
expect(playlist.errors).to be_empty
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
context 'when playlist has mixed items' do
|
|
550
|
+
it 'returns mixed items error' do
|
|
551
|
+
playlist.items << M3u8::PlaylistItem.new(
|
|
552
|
+
bandwidth: 540, uri: 'test.url'
|
|
553
|
+
)
|
|
554
|
+
playlist.items << M3u8::SegmentItem.new(
|
|
555
|
+
duration: 10.0, segment: 'test.ts'
|
|
556
|
+
)
|
|
557
|
+
expect(playlist.errors).to include(
|
|
558
|
+
'Playlist contains both master and media items'
|
|
559
|
+
)
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
240
564
|
describe '#segments' do
|
|
241
565
|
it 'returns only segment items' do
|
|
242
566
|
playlist = described_class.read(
|
|
@@ -330,6 +654,108 @@ describe M3u8::Playlist do
|
|
|
330
654
|
end
|
|
331
655
|
end
|
|
332
656
|
|
|
657
|
+
describe '#freeze' do
|
|
658
|
+
it 'freezes the playlist' do
|
|
659
|
+
playlist.freeze
|
|
660
|
+
expect(playlist).to be_frozen
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
it 'freezes the items array' do
|
|
664
|
+
playlist.freeze
|
|
665
|
+
expect(playlist.items).to be_frozen
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
it 'freezes each item' do
|
|
669
|
+
item = M3u8::SegmentItem.new(duration: 10.0, segment: 'test.ts')
|
|
670
|
+
playlist.items << item
|
|
671
|
+
playlist.freeze
|
|
672
|
+
expect(item).to be_frozen
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
it 'freezes nested byterange on items' do
|
|
676
|
+
item = M3u8::SegmentItem.new(
|
|
677
|
+
duration: 10.0, segment: 'test.ts',
|
|
678
|
+
byterange: { length: 4500, start: 600 }
|
|
679
|
+
)
|
|
680
|
+
playlist.items << item
|
|
681
|
+
playlist.freeze
|
|
682
|
+
expect(item.byterange).to be_frozen
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
it 'freezes nested program_date_time on items' do
|
|
686
|
+
time = M3u8::TimeItem.new(time: '2024-06-01T12:00:00Z')
|
|
687
|
+
item = M3u8::SegmentItem.new(
|
|
688
|
+
duration: 10.0, segment: 'test.ts',
|
|
689
|
+
program_date_time: time
|
|
690
|
+
)
|
|
691
|
+
playlist.items << item
|
|
692
|
+
playlist.freeze
|
|
693
|
+
expect(item.program_date_time).to be_frozen
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
it 'freezes nested client_attributes on items' do
|
|
697
|
+
item = M3u8::DateRangeItem.new(
|
|
698
|
+
id: 'ad-1', start_date: '2024-01-01T00:00:00Z',
|
|
699
|
+
client_attributes: { 'X-AD-ID' => '"foo"' }
|
|
700
|
+
)
|
|
701
|
+
playlist.items << item
|
|
702
|
+
playlist.freeze
|
|
703
|
+
expect(item.client_attributes).to be_frozen
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
it 'freezes part_inf' do
|
|
707
|
+
playlist.part_inf = M3u8::PartInfItem.new(part_target: 0.5)
|
|
708
|
+
playlist.freeze
|
|
709
|
+
expect(playlist.part_inf).to be_frozen
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
it 'freezes server_control' do
|
|
713
|
+
playlist.server_control = M3u8::ServerControlItem.new(
|
|
714
|
+
can_skip_until: 24.0
|
|
715
|
+
)
|
|
716
|
+
playlist.freeze
|
|
717
|
+
expect(playlist.server_control).to be_frozen
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
it 'raises FrozenError on attribute set' do
|
|
721
|
+
playlist.freeze
|
|
722
|
+
expect { playlist.version = 7 }
|
|
723
|
+
.to raise_error(FrozenError)
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
it 'raises FrozenError on items append' do
|
|
727
|
+
playlist.freeze
|
|
728
|
+
item = M3u8::SegmentItem.new(
|
|
729
|
+
duration: 10.0, segment: 'test.ts'
|
|
730
|
+
)
|
|
731
|
+
expect { playlist.items << item }
|
|
732
|
+
.to raise_error(FrozenError)
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
it 'raises FrozenError on item mutation' do
|
|
736
|
+
item = M3u8::SegmentItem.new(
|
|
737
|
+
duration: 10.0, segment: 'test.ts'
|
|
738
|
+
)
|
|
739
|
+
playlist.items << item
|
|
740
|
+
playlist.freeze
|
|
741
|
+
expect { item.duration = 5.0 }
|
|
742
|
+
.to raise_error(FrozenError)
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
it 'still supports to_s' do
|
|
746
|
+
item = M3u8::SegmentItem.new(
|
|
747
|
+
duration: 10.0, segment: 'test.ts'
|
|
748
|
+
)
|
|
749
|
+
playlist.items << item
|
|
750
|
+
playlist.freeze
|
|
751
|
+
expect(playlist.to_s).to include('#EXTM3U')
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
it 'returns self' do
|
|
755
|
+
expect(playlist.freeze).to equal(playlist)
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
|
|
333
759
|
describe '#write' do
|
|
334
760
|
context 'when playlist is valid' do
|
|
335
761
|
it 'returns playlist text' do
|
|
@@ -356,7 +782,8 @@ describe M3u8::Playlist do
|
|
|
356
782
|
item = M3u8::SegmentItem.new(options)
|
|
357
783
|
playlist.items << item
|
|
358
784
|
|
|
359
|
-
message = 'Playlist is invalid
|
|
785
|
+
message = 'Playlist is invalid: Playlist contains both ' \
|
|
786
|
+
'master and media items'
|
|
360
787
|
io = StringIO.new
|
|
361
788
|
expect { playlist.write(io) }
|
|
362
789
|
.to raise_error(M3u8::PlaylistTypeError, message)
|
|
@@ -590,6 +590,20 @@ describe M3u8::Reader do
|
|
|
590
590
|
expect(item.segment).to eq('segment0.ts')
|
|
591
591
|
end
|
|
592
592
|
|
|
593
|
+
it 'returns a frozen master playlist' do
|
|
594
|
+
playlist = reader.read(
|
|
595
|
+
File.read('spec/fixtures/master.m3u8')
|
|
596
|
+
)
|
|
597
|
+
expect(playlist).to be_frozen
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
it 'returns a frozen media playlist' do
|
|
601
|
+
playlist = reader.read(
|
|
602
|
+
File.read('spec/fixtures/playlist.m3u8')
|
|
603
|
+
)
|
|
604
|
+
expect(playlist).to be_frozen
|
|
605
|
+
end
|
|
606
|
+
|
|
593
607
|
context 'when playlist source is invalid' do
|
|
594
608
|
it 'raises error with message' do
|
|
595
609
|
message = 'Playlist must start with a #EXTM3U tag, line read ' \
|
|
@@ -17,7 +17,7 @@ describe M3u8::Writer do
|
|
|
17
17
|
item = M3u8::PlaylistItem.new(options)
|
|
18
18
|
playlist.items << item
|
|
19
19
|
options = { data_id: 'com.test.movie.title', value: 'Test',
|
|
20
|
-
|
|
20
|
+
language: 'en' }
|
|
21
21
|
item = M3u8::SessionDataItem.new(options)
|
|
22
22
|
playlist.items << item
|
|
23
23
|
|
|
@@ -28,7 +28,7 @@ describe M3u8::Writer do
|
|
|
28
28
|
%(RESOLUTION=1920x1080,CODECS="avc1.640029,mp4a.40.2") +
|
|
29
29
|
",BANDWIDTH=50000\nplaylist_url\n" +
|
|
30
30
|
%(#EXT-X-SESSION-DATA:DATA-ID="com.test.movie.title",) +
|
|
31
|
-
%(VALUE="Test",
|
|
31
|
+
%(VALUE="Test",LANGUAGE="en"\n)
|
|
32
32
|
|
|
33
33
|
io = StringIO.open
|
|
34
34
|
writer = described_class.new(io)
|
|
@@ -101,7 +101,7 @@ describe M3u8::Writer do
|
|
|
101
101
|
|
|
102
102
|
context 'when playlist is a media playlist' do
|
|
103
103
|
it 'writes playlist to io' do
|
|
104
|
-
options = { version: 4, cache: false, target:
|
|
104
|
+
options = { version: 4, cache: false, target: 12, sequence: 1,
|
|
105
105
|
discontinuity_sequence: 10, type: 'EVENT',
|
|
106
106
|
iframes_only: true }
|
|
107
107
|
playlist = M3u8::Playlist.new(options)
|
|
@@ -116,7 +116,7 @@ describe M3u8::Writer do
|
|
|
116
116
|
"#EXT-X-MEDIA-SEQUENCE:1\n" \
|
|
117
117
|
"#EXT-X-DISCONTINUITY-SEQUENCE:10\n" \
|
|
118
118
|
"#EXT-X-ALLOW-CACHE:NO\n" \
|
|
119
|
-
"#EXT-X-TARGETDURATION:
|
|
119
|
+
"#EXT-X-TARGETDURATION:12\n" \
|
|
120
120
|
"#EXTINF:11.344644,\n" \
|
|
121
121
|
"1080-7mbps00000.ts\n" \
|
|
122
122
|
"#EXT-X-ENDLIST\n"
|
|
@@ -132,7 +132,7 @@ describe M3u8::Writer do
|
|
|
132
132
|
it 'writes playlist to io' do
|
|
133
133
|
options = { duration: 11.344644, segment: '1080-7mbps00000.ts' }
|
|
134
134
|
item = M3u8::SegmentItem.new(options)
|
|
135
|
-
playlist = M3u8::Playlist.new(version: 7)
|
|
135
|
+
playlist = M3u8::Playlist.new(version: 7, target: 12)
|
|
136
136
|
playlist.items << item
|
|
137
137
|
|
|
138
138
|
options = { method: 'AES-128', uri: 'http://test.key',
|
|
@@ -148,7 +148,7 @@ describe M3u8::Writer do
|
|
|
148
148
|
expected = "#EXTM3U\n" \
|
|
149
149
|
"#EXT-X-VERSION:7\n" \
|
|
150
150
|
"#EXT-X-MEDIA-SEQUENCE:0\n" \
|
|
151
|
-
"#EXT-X-TARGETDURATION:
|
|
151
|
+
"#EXT-X-TARGETDURATION:12\n" \
|
|
152
152
|
"#EXTINF:11.344644,\n" \
|
|
153
153
|
"1080-7mbps00000.ts\n" +
|
|
154
154
|
%(#EXT-X-KEY:METHOD=AES-128,URI="http://test.key",) +
|
|
@@ -204,10 +204,10 @@ describe M3u8::Writer do
|
|
|
204
204
|
end
|
|
205
205
|
end
|
|
206
206
|
|
|
207
|
-
it 'raises error if item types are mixed' do
|
|
207
|
+
it 'raises error with specific messages if item types are mixed' do
|
|
208
208
|
playlist = M3u8::Playlist.new
|
|
209
|
-
options = { program_id: 1, width: 1920, height: 1080,
|
|
210
|
-
bandwidth: 540,
|
|
209
|
+
options = { program_id: 1, width: 1920, height: 1080,
|
|
210
|
+
codecs: 'avc', bandwidth: 540, uri: 'test.url' }
|
|
211
211
|
item = M3u8::PlaylistItem.new(options)
|
|
212
212
|
playlist.items << item
|
|
213
213
|
|
|
@@ -215,7 +215,8 @@ describe M3u8::Writer do
|
|
|
215
215
|
item = M3u8::SegmentItem.new(options)
|
|
216
216
|
playlist.items << item
|
|
217
217
|
|
|
218
|
-
message = 'Playlist is invalid
|
|
218
|
+
message = 'Playlist is invalid: Playlist contains both ' \
|
|
219
|
+
'master and media items'
|
|
219
220
|
io = StringIO.new
|
|
220
221
|
writer = described_class.new(io)
|
|
221
222
|
expect { writer.write(playlist) }
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: m3u8
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Seth Deckard
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-03-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|