m3u8 0.8.2 → 1.8.1

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 (103) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +23 -0
  3. data/.gitignore +1 -1
  4. data/.rubocop.yml +31 -0
  5. data/CHANGELOG.md +107 -0
  6. data/Gemfile +7 -0
  7. data/LICENSE.txt +1 -1
  8. data/README.md +524 -40
  9. data/Rakefile +1 -0
  10. data/bin/m3u8 +6 -0
  11. data/lib/m3u8/attribute_formatter.rb +47 -0
  12. data/lib/m3u8/bitrate_item.rb +31 -0
  13. data/lib/m3u8/builder.rb +48 -0
  14. data/lib/m3u8/byte_range.rb +10 -0
  15. data/lib/m3u8/cli/inspect_command.rb +97 -0
  16. data/lib/m3u8/cli/validate_command.rb +24 -0
  17. data/lib/m3u8/cli.rb +116 -0
  18. data/lib/m3u8/codecs.rb +89 -0
  19. data/lib/m3u8/content_steering_item.rb +45 -0
  20. data/lib/m3u8/date_range_item.rb +135 -64
  21. data/lib/m3u8/define_item.rb +54 -0
  22. data/lib/m3u8/discontinuity_item.rb +3 -0
  23. data/lib/m3u8/encryptable.rb +27 -30
  24. data/lib/m3u8/error.rb +1 -0
  25. data/lib/m3u8/gap_item.rb +14 -0
  26. data/lib/m3u8/key_item.rb +7 -0
  27. data/lib/m3u8/map_item.rb +16 -5
  28. data/lib/m3u8/media_item.rb +48 -76
  29. data/lib/m3u8/part_inf_item.rb +35 -0
  30. data/lib/m3u8/part_item.rb +67 -0
  31. data/lib/m3u8/playback_start.rb +19 -12
  32. data/lib/m3u8/playlist.rb +221 -13
  33. data/lib/m3u8/playlist_item.rb +128 -124
  34. data/lib/m3u8/preload_hint_item.rb +54 -0
  35. data/lib/m3u8/reader.rb +86 -28
  36. data/lib/m3u8/rendition_report_item.rb +48 -0
  37. data/lib/m3u8/scte35.rb +130 -0
  38. data/lib/m3u8/scte35_bit_reader.rb +51 -0
  39. data/lib/m3u8/scte35_segmentation_descriptor.rb +54 -0
  40. data/lib/m3u8/scte35_splice_insert.rb +62 -0
  41. data/lib/m3u8/scte35_splice_null.rb +8 -0
  42. data/lib/m3u8/scte35_time_signal.rb +19 -0
  43. data/lib/m3u8/segment_item.rb +37 -3
  44. data/lib/m3u8/server_control_item.rb +69 -0
  45. data/lib/m3u8/session_data_item.rb +17 -28
  46. data/lib/m3u8/session_key_item.rb +8 -1
  47. data/lib/m3u8/skip_item.rb +48 -0
  48. data/lib/m3u8/time_item.rb +10 -0
  49. data/lib/m3u8/version.rb +1 -1
  50. data/lib/m3u8/writer.rb +24 -1
  51. data/lib/m3u8.rb +30 -6
  52. data/m3u8.gemspec +12 -12
  53. data/spec/fixtures/content_steering.m3u8 +10 -0
  54. data/spec/fixtures/daterange_playlist.m3u8 +14 -0
  55. data/spec/fixtures/encrypted_discontinuity.m3u8 +17 -0
  56. data/spec/fixtures/event_playlist.m3u8 +18 -0
  57. data/spec/fixtures/gap_playlist.m3u8 +14 -0
  58. data/spec/fixtures/ll_hls_advanced.m3u8 +18 -0
  59. data/spec/fixtures/ll_hls_playlist.m3u8 +20 -0
  60. data/spec/fixtures/master_full.m3u8 +14 -0
  61. data/spec/fixtures/master_v13.m3u8 +8 -0
  62. data/spec/lib/m3u8/bitrate_item_spec.rb +26 -0
  63. data/spec/lib/m3u8/builder_spec.rb +352 -0
  64. data/spec/lib/m3u8/byte_range_spec.rb +1 -0
  65. data/spec/lib/m3u8/cli/inspect_command_spec.rb +102 -0
  66. data/spec/lib/m3u8/cli/validate_command_spec.rb +39 -0
  67. data/spec/lib/m3u8/cli_spec.rb +104 -0
  68. data/spec/lib/m3u8/content_steering_item_spec.rb +56 -0
  69. data/spec/lib/m3u8/date_range_item_spec.rb +159 -31
  70. data/spec/lib/m3u8/define_item_spec.rb +59 -0
  71. data/spec/lib/m3u8/discontinuity_item_spec.rb +1 -0
  72. data/spec/lib/m3u8/gap_item_spec.rb +12 -0
  73. data/spec/lib/m3u8/key_item_spec.rb +1 -0
  74. data/spec/lib/m3u8/map_item_spec.rb +1 -0
  75. data/spec/lib/m3u8/media_item_spec.rb +34 -0
  76. data/spec/lib/m3u8/part_inf_item_spec.rb +27 -0
  77. data/spec/lib/m3u8/part_item_spec.rb +67 -0
  78. data/spec/lib/m3u8/playback_start_spec.rb +4 -5
  79. data/spec/lib/m3u8/playlist_item_spec.rb +130 -17
  80. data/spec/lib/m3u8/playlist_spec.rb +545 -13
  81. data/spec/lib/m3u8/preload_hint_item_spec.rb +57 -0
  82. data/spec/lib/m3u8/reader_spec.rb +376 -29
  83. data/spec/lib/m3u8/rendition_report_item_spec.rb +56 -0
  84. data/spec/lib/m3u8/round_trip_spec.rb +152 -0
  85. data/spec/lib/m3u8/scte35_bit_reader_spec.rb +106 -0
  86. data/spec/lib/m3u8/scte35_segmentation_descriptor_spec.rb +143 -0
  87. data/spec/lib/m3u8/scte35_spec.rb +94 -0
  88. data/spec/lib/m3u8/scte35_splice_insert_spec.rb +185 -0
  89. data/spec/lib/m3u8/scte35_splice_null_spec.rb +12 -0
  90. data/spec/lib/m3u8/scte35_time_signal_spec.rb +50 -0
  91. data/spec/lib/m3u8/segment_item_spec.rb +47 -0
  92. data/spec/lib/m3u8/server_control_item_spec.rb +64 -0
  93. data/spec/lib/m3u8/session_data_item_spec.rb +1 -0
  94. data/spec/lib/m3u8/session_key_item_spec.rb +1 -0
  95. data/spec/lib/m3u8/skip_item_spec.rb +48 -0
  96. data/spec/lib/m3u8/time_item_spec.rb +1 -0
  97. data/spec/lib/m3u8/writer_spec.rb +69 -30
  98. data/spec/lib/m3u8_spec.rb +1 -0
  99. data/spec/spec_helper.rb +4 -87
  100. metadata +70 -129
  101. data/.hound.yml +0 -3
  102. data/.travis.yml +0 -8
  103. data/Guardfile +0 -6
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'spec_helper'
3
4
 
4
5
  describe M3u8::Playlist do
@@ -43,12 +44,25 @@ describe M3u8::Playlist do
43
44
  codecs = described_class.codecs(options)
44
45
  expect(codecs).to eq('avc1.66.30,mp4a.40.2')
45
46
  end
47
+
48
+ it 'generates HEVC codecs string' do
49
+ options = { profile: 'hevc-main', level: 4.0, audio_codec: 'ac-3' }
50
+ codecs = described_class.codecs(options)
51
+ expect(codecs).to eq('hvc1.1.6.L120.B0,ac-3')
52
+ end
53
+
54
+ it 'generates AV1 codecs string' do
55
+ options = { profile: 'av1-main', level: 5.0, audio_codec: 'opus' }
56
+ codecs = described_class.codecs(options)
57
+ expect(codecs).to eq('av01.0.12M.08,Opus')
58
+ end
46
59
  end
47
60
 
48
61
  describe '.read' do
49
62
  it 'returns new playlist from content' do
50
- file = File.open('spec/fixtures/master.m3u8')
51
- playlist = described_class.read(file)
63
+ playlist = described_class.read(
64
+ File.read('spec/fixtures/master.m3u8')
65
+ )
52
66
  expect(playlist.master?).to be true
53
67
  expect(playlist.items.size).to eq(8)
54
68
  end
@@ -168,6 +182,7 @@ describe M3u8::Playlist do
168
182
  end
169
183
 
170
184
  it 'returns media playlist text' do
185
+ playlist = described_class.new(target: 12)
171
186
  options = { duration: 11.344644, segment: '1080-7mbps00000.ts' }
172
187
  item = M3u8::SegmentItem.new(options)
173
188
  playlist.items << item
@@ -177,13 +192,13 @@ describe M3u8::Playlist do
177
192
  playlist.items << item
178
193
 
179
194
  expected = "#EXTM3U\n" \
180
- "#EXT-X-MEDIA-SEQUENCE:0\n" \
181
- "#EXT-X-TARGETDURATION:10\n" \
182
- "#EXTINF:11.344644,\n" \
183
- "1080-7mbps00000.ts\n" \
184
- "#EXTINF:11.261233,\n" \
185
- "1080-7mbps00001.ts\n" \
186
- "#EXT-X-ENDLIST\n"
195
+ "#EXT-X-MEDIA-SEQUENCE:0\n" \
196
+ "#EXT-X-TARGETDURATION:12\n" \
197
+ "#EXTINF:11.344644,\n" \
198
+ "1080-7mbps00000.ts\n" \
199
+ "#EXTINF:11.261233,\n" \
200
+ "1080-7mbps00001.ts\n" \
201
+ "#EXT-X-ENDLIST\n"
187
202
  expect(playlist.to_s).to eq(expected)
188
203
  end
189
204
  end
@@ -223,6 +238,524 @@ describe M3u8::Playlist do
223
238
  end
224
239
  end
225
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
+
564
+ describe '#segments' do
565
+ it 'returns only segment items' do
566
+ playlist = described_class.read(
567
+ File.read('spec/fixtures/playlist.m3u8')
568
+ )
569
+ expect(playlist.segments).to all be_a(M3u8::SegmentItem)
570
+ expect(playlist.segments.size).to eq(138)
571
+ end
572
+ end
573
+
574
+ describe '#playlists' do
575
+ it 'returns only playlist items' do
576
+ playlist = described_class.read(
577
+ File.read('spec/fixtures/master.m3u8')
578
+ )
579
+ expect(playlist.playlists).to all be_a(M3u8::PlaylistItem)
580
+ expect(playlist.playlists.size).to eq(6)
581
+ end
582
+ end
583
+
584
+ describe '#media_items' do
585
+ it 'returns only media items' do
586
+ playlist = described_class.read(
587
+ File.read('spec/fixtures/variant_audio.m3u8')
588
+ )
589
+ expect(playlist.media_items).to all be_a(M3u8::MediaItem)
590
+ expect(playlist.media_items.size).to eq(6)
591
+ end
592
+ end
593
+
594
+ describe '#keys' do
595
+ it 'returns only key items' do
596
+ playlist = described_class.read(
597
+ File.read('spec/fixtures/encrypted.m3u8')
598
+ )
599
+ expect(playlist.keys).to all be_a(M3u8::KeyItem)
600
+ expect(playlist.keys.size).to eq(2)
601
+ end
602
+ end
603
+
604
+ describe '#maps' do
605
+ it 'returns only map items' do
606
+ playlist = described_class.read(
607
+ File.read('spec/fixtures/map_playlist.m3u8')
608
+ )
609
+ expect(playlist.maps).to all be_a(M3u8::MapItem)
610
+ expect(playlist.maps.size).to eq(1)
611
+ end
612
+ end
613
+
614
+ describe '#date_ranges' do
615
+ it 'returns only date range items' do
616
+ playlist = described_class.read(
617
+ File.read('spec/fixtures/daterange_playlist.m3u8')
618
+ )
619
+ expect(playlist.date_ranges)
620
+ .to all be_a(M3u8::DateRangeItem)
621
+ expect(playlist.date_ranges.size).to eq(3)
622
+ end
623
+ end
624
+
625
+ describe '#parts' do
626
+ it 'returns only part items' do
627
+ playlist = described_class.read(
628
+ File.read('spec/fixtures/ll_hls_playlist.m3u8')
629
+ )
630
+ expect(playlist.parts).to all be_a(M3u8::PartItem)
631
+ expect(playlist.parts.size).to eq(5)
632
+ end
633
+ end
634
+
635
+ describe '#session_data' do
636
+ it 'returns only session data items' do
637
+ playlist = described_class.read(
638
+ File.read('spec/fixtures/session_data.m3u8')
639
+ )
640
+ expect(playlist.session_data)
641
+ .to all be_a(M3u8::SessionDataItem)
642
+ expect(playlist.session_data.size).to eq(3)
643
+ end
644
+ end
645
+
646
+ describe '#session_keys' do
647
+ it 'returns only session key items' do
648
+ playlist = described_class.read(
649
+ File.read('spec/fixtures/master.m3u8')
650
+ )
651
+ expect(playlist.session_keys)
652
+ .to all be_a(M3u8::SessionKeyItem)
653
+ expect(playlist.session_keys.size).to eq(1)
654
+ end
655
+ end
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
+
226
759
  describe '#write' do
227
760
  context 'when playlist is valid' do
228
761
  it 'returns playlist text' do
@@ -233,9 +766,7 @@ describe M3u8::Playlist do
233
766
 
234
767
  io = StringIO.new
235
768
  playlist.write(io)
236
- expected = "#EXTM3U\n" +
237
- %(#EXT-X-STREAM-INF:PROGRAM-ID=1,CODECS="mp4a.40.34",) +
238
- "BANDWIDTH=6400\nplaylist_url\n"
769
+ expected = "#EXTM3U\n#EXT-X-STREAM-INF:PROGRAM-ID=1,CODECS=\"mp4a.40.34\",BANDWIDTH=6400\nplaylist_url\n"
239
770
  expect(io.string).to eq(expected)
240
771
  end
241
772
  end
@@ -251,7 +782,8 @@ describe M3u8::Playlist do
251
782
  item = M3u8::SegmentItem.new(options)
252
783
  playlist.items << item
253
784
 
254
- message = 'Playlist is invalid.'
785
+ message = 'Playlist is invalid: Playlist contains both ' \
786
+ 'master and media items'
255
787
  io = StringIO.new
256
788
  expect { playlist.write(io) }
257
789
  .to raise_error(M3u8::PlaylistTypeError, message)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe M3u8::PreloadHintItem do
6
+ describe '.new' do
7
+ it 'assigns attributes from options' do
8
+ options = { type: 'PART', uri: 'next_part.ts',
9
+ byterange_start: 0, byterange_length: 5000 }
10
+ item = described_class.new(options)
11
+ expect(item.type).to eq('PART')
12
+ expect(item.uri).to eq('next_part.ts')
13
+ expect(item.byterange_start).to eq(0)
14
+ expect(item.byterange_length).to eq(5000)
15
+ end
16
+ end
17
+
18
+ describe '.parse' do
19
+ it 'parses tag with all attributes' do
20
+ tag = '#EXT-X-PRELOAD-HINT:TYPE=PART,URI="next_part.ts",' \
21
+ 'BYTERANGE-START=0,BYTERANGE-LENGTH=5000'
22
+ item = described_class.parse(tag)
23
+ expect(item.type).to eq('PART')
24
+ expect(item.uri).to eq('next_part.ts')
25
+ expect(item.byterange_start).to eq(0)
26
+ expect(item.byterange_length).to eq(5000)
27
+ end
28
+
29
+ it 'parses tag without optional attributes' do
30
+ tag = '#EXT-X-PRELOAD-HINT:TYPE=PART,URI="next_part.ts"'
31
+ item = described_class.parse(tag)
32
+ expect(item.type).to eq('PART')
33
+ expect(item.uri).to eq('next_part.ts')
34
+ expect(item.byterange_start).to be_nil
35
+ expect(item.byterange_length).to be_nil
36
+ end
37
+ end
38
+
39
+ describe '#to_s' do
40
+ it 'returns tag with all attributes' do
41
+ options = { type: 'PART', uri: 'next_part.ts',
42
+ byterange_start: 0, byterange_length: 5000 }
43
+ item = described_class.new(options)
44
+ expected = '#EXT-X-PRELOAD-HINT:TYPE=PART,' \
45
+ 'URI="next_part.ts",' \
46
+ 'BYTERANGE-START=0,BYTERANGE-LENGTH=5000'
47
+ expect(item.to_s).to eq(expected)
48
+ end
49
+
50
+ it 'returns tag without optional attributes' do
51
+ options = { type: 'PART', uri: 'next_part.ts' }
52
+ item = described_class.new(options)
53
+ expected = '#EXT-X-PRELOAD-HINT:TYPE=PART,URI="next_part.ts"'
54
+ expect(item.to_s).to eq(expected)
55
+ end
56
+ end
57
+ end