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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7112d267f1f0b5e6f89f4c5b54392b02dc3d37d39d9cbda88d2125b69b89ca37
4
- data.tar.gz: 6755f10b8de1fbb8d06f881b29973623fa48e53347b54f3093c37ff794817090
3
+ metadata.gz: df37c1a4cc89e0bd918c6137c5bc86682d61638b407697240e76f40acb8983e7
4
+ data.tar.gz: e19fd10c1b3f732057285247f08bb235573fa6434085295b53a76be22f1a53aa
5
5
  SHA512:
6
- metadata.gz: 635a0727ca10232fd961568d72210bbf96ed1a52c64ed0326a98f275ed1948f2d6016abd1edc774972365f459508e92bd0048e5fa511e4864683d196a2eaddbe
7
- data.tar.gz: 6afceffd0ce2b396737e1963ce89b7d1e31177d12d1226c4f705feff4e3625110de05fb108973eac0203566e018f9aa8ac470a0825ee7c581b86753687320009
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: mixed playlist and segment items
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
@@ -14,7 +14,8 @@ module M3u8
14
14
  @stdout.puts 'Valid'
15
15
  0
16
16
  else
17
- @stdout.puts 'Invalid: mixed playlist and segment items'
17
+ @stdout.puts 'Invalid'
18
+ @playlist.errors.each { |e| @stdout.puts " - #{e}" }
18
19
  1
19
20
  end
20
21
  end
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 valid?
63
- return false if playlist_size.positive? && segment_size.positive?
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
- true
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
@@ -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'].to_i,
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
@@ -25,7 +25,7 @@ module M3u8
25
25
  parse_line(line)
26
26
  end
27
27
  playlist.live = !@has_endlist unless master
28
- playlist
28
+ playlist.freeze
29
29
  end
30
30
 
31
31
  private
data/lib/m3u8/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  # M3u8 provides parsing, generation, and validation of m3u8 playlists
4
4
  module M3u8
5
- VERSION = '1.3.1'
5
+ VERSION = '1.5.0'
6
6
  end
data/lib/m3u8/writer.rb CHANGED
@@ -44,7 +44,8 @@ module M3u8
44
44
  def validate(playlist)
45
45
  return if playlist.valid?
46
46
 
47
- raise PlaylistTypeError, 'Playlist is invalid.'
47
+ raise PlaylistTypeError,
48
+ "Playlist is invalid: #{playlist.errors.join('; ')}"
48
49
  end
49
50
 
50
51
  def write_cache_tag(cache)
@@ -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
- expect(stdout.string.strip).to include('Invalid')
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:10\n" \
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
- uri: 'http://test', language: 'en' }
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",URI="http://test",LANGUAGE="en"\n)
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: 6.2, sequence: 1,
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:6\n" \
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:10\n" \
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, codecs: 'avc',
210
- bandwidth: 540, playlist: 'test.url' }
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.3.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-02-28 00:00:00.000000000 Z
11
+ date: 2026-03-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler