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
data/lib/m3u8/writer.rb CHANGED
@@ -1,13 +1,19 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module M3u8
3
4
  # Writer provides generation of text output of playlists in m3u8 format
4
5
  class Writer
6
+ # @return [IO] output stream
5
7
  attr_accessor :io
6
8
 
9
+ # @param io [IO] writable IO object
7
10
  def initialize(io)
8
11
  @io = io
9
12
  end
10
13
 
14
+ # Write a playlist to the output stream.
15
+ # @param playlist [Playlist] playlist to write
16
+ # @return [void]
11
17
  def write(playlist)
12
18
  validate(playlist)
13
19
  write_header(playlist)
@@ -21,6 +27,7 @@ module M3u8
21
27
 
22
28
  def write_footer(playlist)
23
29
  return if playlist.live? || playlist.master?
30
+
24
31
  io.puts '#EXT-X-ENDLIST'
25
32
  end
26
33
 
@@ -41,7 +48,9 @@ module M3u8
41
48
 
42
49
  def validate(playlist)
43
50
  return if playlist.valid?
44
- raise PlaylistTypeError, 'Playlist is invalid.'
51
+
52
+ raise PlaylistTypeError,
53
+ "Playlist is invalid: #{playlist.errors.join('; ')}"
45
54
  end
46
55
 
47
56
  def write_cache_tag(cache)
@@ -76,6 +85,20 @@ module M3u8
76
85
  write_discontinuity_sequence_tag(playlist.discontinuity_sequence)
77
86
  write_cache_tag(playlist.cache)
78
87
  io.puts target_duration_format(playlist)
88
+ write_server_control_tag(playlist.server_control)
89
+ write_part_inf_tag(playlist.part_inf)
90
+ end
91
+
92
+ def write_server_control_tag(server_control)
93
+ return if server_control.nil?
94
+
95
+ io.puts server_control.to_s
96
+ end
97
+
98
+ def write_part_inf_tag(part_inf)
99
+ return if part_inf.nil?
100
+
101
+ io.puts part_inf.to_s
79
102
  end
80
103
 
81
104
  def write_version_tag(version)
data/lib/m3u8.rb CHANGED
@@ -1,30 +1,54 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'bigdecimal'
3
4
  require 'stringio'
4
- Dir[File.dirname(__FILE__) + '/m3u8/*.rb'].sort.each { |file| require file }
5
+ Dir["#{File.dirname(__FILE__)}/m3u8/*.rb"].each { |file| require file }
5
6
 
6
7
  # M3u8 provides parsing, generation, and validation of m3u8 playlists
7
8
  module M3u8
8
- def intialize_with_byterange(params = {})
9
+ # Initialize attributes from a params hash, converting any Hash
10
+ # values for :byterange into ByteRange instances.
11
+ # @param params [Hash] attribute key-value pairs
12
+ # @return [void]
13
+ def initialize_with_byterange(params = {})
9
14
  params.each do |key, value|
10
15
  value = ByteRange.new(value) if value.is_a?(Hash)
11
16
  instance_variable_set("@#{key}", value)
12
17
  end
13
18
  end
14
19
 
20
+ # Parse an HLS attribute list string into a Hash.
21
+ # @param line [String] raw attribute list (e.g. 'KEY="val",NUM=1')
22
+ # @return [Hash<String, String>] attribute name-value pairs
15
23
  def parse_attributes(line)
16
- array = line.delete("\n").scan(/([A-z0-9-]+)\s*=\s*("[^"]*"|[^,]*)/)
17
- Hash[array.map { |key, value| [key, value.delete('"')] }]
24
+ line.delete("\n").scan(/([A-Za-z0-9-]+)\s*=\s*("[^"]*"|[^,]*)/)
25
+ .to_h { |key, value| [key, value.delete('"')] }
18
26
  end
19
27
 
28
+ # Convert a string value to Float, returning nil when nil.
29
+ # @param value [String, nil] numeric string
30
+ # @return [Float, nil]
20
31
  def parse_float(value)
21
- value.nil? ? nil : value.to_f
32
+ value&.to_f
33
+ end
34
+
35
+ # Convert a string value to Integer, returning nil when nil.
36
+ # @param value [String, nil] numeric string
37
+ # @return [Integer, nil]
38
+ def parse_int(value)
39
+ value&.to_i
22
40
  end
23
41
 
42
+ # Parse an HLS YES/NO attribute into a boolean.
43
+ # @param value [String] 'YES' or 'NO'
44
+ # @return [Boolean]
24
45
  def parse_yes_no(value)
25
- value == 'YES' ? true : false
46
+ value == 'YES'
26
47
  end
27
48
 
49
+ # Convert a boolean into an HLS YES/NO string.
50
+ # @param boolean [Boolean] value to convert
51
+ # @return [String] 'YES' or 'NO'
28
52
  def to_yes_no(boolean)
29
53
  boolean == true ? 'YES' : 'NO'
30
54
  end
data/m3u8.gemspec CHANGED
@@ -1,5 +1,4 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
1
+ lib = File.expand_path('lib', __dir__)
3
2
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
3
  require 'm3u8/version'
5
4
 
@@ -8,21 +7,22 @@ Gem::Specification.new do |spec|
8
7
  spec.version = M3u8::VERSION
9
8
  spec.authors = ['Seth Deckard']
10
9
  spec.email = ['seth@deckard.me']
11
- spec.summary = %q{Generate and parse m3u8 playlists for HTTP Live Streaming (HLS).}
12
- spec.description = %q{Generate and parse m3u8 playlists for HTTP Live Streaming (HLS).}
10
+ spec.summary = 'Generate and parse m3u8 playlists for HTTP Live Streaming (HLS).'
11
+ spec.description = 'Generate and parse m3u8 playlists for HTTP Live Streaming (HLS).'
13
12
  spec.homepage = 'https://github.com/sethdeckard/m3u8'
14
13
  spec.license = 'MIT'
14
+ spec.required_ruby_version = '>= 3.1'
15
+
16
+ spec.metadata = {
17
+ 'source_code_uri' => 'https://github.com/sethdeckard/m3u8',
18
+ 'changelog_uri' => 'https://github.com/sethdeckard/m3u8/blob/master/CHANGELOG.md',
19
+ 'bug_tracker_uri' => 'https://github.com/sethdeckard/m3u8/issues'
20
+ }
15
21
 
16
22
  spec.files = `git ls-files -z`.split("\x0")
23
+ .grep_v(/\A(CLAUDE|AGENTS)\.md\z/)
17
24
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
- spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
25
+ spec.add_dependency 'bigdecimal'
19
26
  spec.require_paths = ['lib']
20
27
 
21
- spec.add_development_dependency 'bundler'
22
- spec.add_development_dependency 'rake'
23
- spec.add_development_dependency 'rspec', '>=3.0'
24
- spec.add_development_dependency 'coveralls'
25
- spec.add_development_dependency 'guard'
26
- spec.add_development_dependency 'guard-rspec'
27
- spec.add_development_dependency 'simplecov'
28
28
  end
@@ -0,0 +1,10 @@
1
+ #EXTM3U
2
+ #EXT-X-VERSION:13
3
+ #EXT-X-INDEPENDENT-SEGMENTS
4
+ #EXT-X-DEFINE:NAME="base_url",VALUE="https://example.com"
5
+ #EXT-X-DEFINE:IMPORT="token"
6
+ #EXT-X-CONTENT-STEERING:SERVER-URI="https://example.com/steering",PATHWAY-ID="CDN-A"
7
+ #EXT-X-STREAM-INF:BANDWIDTH=2000000,CODECS="avc1.640028,mp4a.40.2",RESOLUTION=1280x720
8
+ 720p.m3u8
9
+ #EXT-X-STREAM-INF:BANDWIDTH=5000000,CODECS="avc1.640028,mp4a.40.2",RESOLUTION=1920x1080
10
+ 1080p.m3u8
@@ -0,0 +1,14 @@
1
+ #EXTM3U
2
+ #EXT-X-VERSION:7
3
+ #EXT-X-MEDIA-SEQUENCE:0
4
+ #EXT-X-TARGETDURATION:10
5
+ #EXT-X-DATERANGE:ID="ad-1",CLASS="com.example.ad",START-DATE="2024-01-01T00:00:00Z",END-DATE="2024-01-01T00:00:30Z",DURATION=30.0,X-AD-ID="ad-123",X-AD-URL="https://example.com/ad"
6
+ #EXTINF:10.0,
7
+ segment0.ts
8
+ #EXT-X-DATERANGE:ID="ad-2",CLASS="com.example.ad",START-DATE="2024-01-01T00:00:30Z",PLANNED-DURATION=15.0,X-AD-ID="ad-456"
9
+ #EXTINF:10.0,
10
+ segment1.ts
11
+ #EXT-X-DATERANGE:ID="ch-1",START-DATE="2024-01-01T00:01:00Z",END-ON-NEXT=YES
12
+ #EXTINF:10.0,
13
+ segment2.ts
14
+ #EXT-X-ENDLIST
@@ -0,0 +1,17 @@
1
+ #EXTM3U
2
+ #EXT-X-VERSION:6
3
+ #EXT-X-MEDIA-SEQUENCE:0
4
+ #EXT-X-TARGETDURATION:10
5
+ #EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key1.bin",IV=0x00000000000000000000000000000001
6
+ #EXTINF:10.0,
7
+ segment0.ts
8
+ #EXTINF:10.0,
9
+ segment1.ts
10
+ #EXT-X-DISCONTINUITY
11
+ #EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key2.bin",IV=0x00000000000000000000000000000002
12
+ #EXTINF:10.0,
13
+ segment2.ts
14
+ #EXT-X-KEY:METHOD=NONE
15
+ #EXTINF:10.0,
16
+ segment3.ts
17
+ #EXT-X-ENDLIST
@@ -0,0 +1,18 @@
1
+ #EXTM3U
2
+ #EXT-X-PLAYLIST-TYPE:EVENT
3
+ #EXT-X-VERSION:7
4
+ #EXT-X-INDEPENDENT-SEGMENTS
5
+ #EXT-X-MEDIA-SEQUENCE:0
6
+ #EXT-X-TARGETDURATION:6
7
+ #EXT-X-MAP:URI="init.mp4"
8
+ #EXTINF:6.0,
9
+ #EXT-X-BYTERANGE:75232@0
10
+ segment0.mp4
11
+ #EXTINF:6.0,
12
+ #EXT-X-BYTERANGE:82112@75232
13
+ segment1.mp4
14
+ #EXT-X-DISCONTINUITY
15
+ #EXT-X-MAP:URI="init2.mp4"
16
+ #EXTINF:5.0,
17
+ segment2.mp4
18
+ #EXT-X-ENDLIST
@@ -0,0 +1,14 @@
1
+ #EXTM3U
2
+ #EXT-X-VERSION:6
3
+ #EXT-X-TARGETDURATION:10
4
+ #EXT-X-MEDIA-SEQUENCE:1
5
+ #EXT-X-BITRATE:128
6
+ #EXTINF:10.0,
7
+ segment1.ts
8
+ #EXT-X-GAP
9
+ #EXTINF:10.0,
10
+ gap_segment.ts
11
+ #EXT-X-BITRATE:256
12
+ #EXTINF:10.0,
13
+ segment3.ts
14
+ #EXT-X-ENDLIST
@@ -0,0 +1,18 @@
1
+ #EXTM3U
2
+ #EXT-X-VERSION:9
3
+ #EXT-X-MEDIA-SEQUENCE:100
4
+ #EXT-X-TARGETDURATION:4
5
+ #EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24.0,CAN-SKIP-DATERANGES=YES,HOLD-BACK=12.0,PART-HOLD-BACK=1.0,CAN-BLOCK-RELOAD=YES
6
+ #EXT-X-PART-INF:PART-TARGET=0.5
7
+ #EXT-X-SKIP:SKIPPED-SEGMENTS=10,RECENTLY-REMOVED-DATERANGES="dr-1"
8
+ #EXT-X-MAP:URI="init.mp4"
9
+ #EXTINF:4.0,
10
+ segment100.mp4
11
+ #EXT-X-DISCONTINUITY
12
+ #EXT-X-PART:DURATION=0.5,URI="segment101.0.mp4",INDEPENDENT=YES
13
+ #EXT-X-PART:DURATION=0.5,URI="segment101.1.mp4",BYTERANGE="1024@0",GAP=YES
14
+ #EXT-X-PART:DURATION=0.5,URI="segment101.2.mp4"
15
+ #EXTINF:4.0,
16
+ segment101.mp4
17
+ #EXT-X-PRELOAD-HINT:TYPE=MAP,URI="init2.mp4"
18
+ #EXT-X-RENDITION-REPORT:URI="../720p/stream.m3u8",LAST-MSN=101,LAST-PART=2
@@ -0,0 +1,20 @@
1
+ #EXTM3U
2
+ #EXT-X-VERSION:9
3
+ #EXT-X-TARGETDURATION:4
4
+ #EXT-X-MEDIA-SEQUENCE:100
5
+ #EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24.0,CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.0
6
+ #EXT-X-PART-INF:PART-TARGET=0.5
7
+ #EXT-X-SKIP:SKIPPED-SEGMENTS=5
8
+ #EXT-X-MAP:URI="init.mp4"
9
+ #EXTINF:4.0,
10
+ segment100.mp4
11
+ #EXT-X-PART:DURATION=0.5,URI="segment101.0.mp4",INDEPENDENT=YES
12
+ #EXT-X-PART:DURATION=0.5,URI="segment101.1.mp4"
13
+ #EXT-X-PART:DURATION=0.5,URI="segment101.2.mp4"
14
+ #EXT-X-PART:DURATION=0.5,URI="segment101.3.mp4"
15
+ #EXTINF:4.0,
16
+ segment101.mp4
17
+ #EXT-X-PART:DURATION=0.5,URI="segment102.0.mp4",INDEPENDENT=YES
18
+ #EXT-X-PRELOAD-HINT:TYPE=PART,URI="segment102.1.mp4"
19
+ #EXT-X-RENDITION-REPORT:URI="../720p/stream.m3u8",LAST-MSN=101,LAST-PART=3
20
+ #EXT-X-RENDITION-REPORT:URI="../480p/stream.m3u8",LAST-MSN=101,LAST-PART=3
@@ -0,0 +1,14 @@
1
+ #EXTM3U
2
+ #EXT-X-VERSION:13
3
+ #EXT-X-INDEPENDENT-SEGMENTS
4
+ #EXT-X-DEFINE:QUERYPARAM="token"
5
+ #EXT-X-START:TIME-OFFSET=10.5,PRECISE=YES
6
+ #EXT-X-SESSION-KEY:METHOD=SAMPLE-AES,URI="https://example.com/key.bin",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1"
7
+ #EXT-X-SESSION-DATA:DATA-ID="com.example.title",VALUE="Example Stream",LANGUAGE="en"
8
+ #EXT-X-CONTENT-STEERING:SERVER-URI="https://example.com/steering",PATHWAY-ID="CDN-A"
9
+ #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="en",NAME="English",AUTOSELECT=YES,DEFAULT=YES,URI="audio/en.m3u8",FORCED=NO
10
+ #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="es",NAME="Spanish",AUTOSELECT=YES,DEFAULT=NO,URI="audio/es.m3u8",FORCED=NO
11
+ #EXT-X-STREAM-INF:CODECS="avc1.640028,mp4a.40.2",BANDWIDTH=5000000,AVERAGE-BANDWIDTH=4500000,FRAME-RATE=29.970,AUDIO="aac"
12
+ video/1080.m3u8
13
+ #EXT-X-STREAM-INF:CODECS="avc1.4d001f,mp4a.40.2",BANDWIDTH=2000000,AVERAGE-BANDWIDTH=1800000,FRAME-RATE=29.970,AUDIO="aac"
14
+ video/720.m3u8
@@ -0,0 +1,8 @@
1
+ #EXTM3U
2
+ #EXT-X-VERSION:13
3
+ #EXT-X-INDEPENDENT-SEGMENTS
4
+ #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,CHANNELS="2",STABLE-RENDITION-ID="audio-en",BIT-DEPTH=16,SAMPLE-RATE=44100,URI="audio/en.m3u8"
5
+ #EXT-X-STREAM-INF:BANDWIDTH=5000000,AVERAGE-BANDWIDTH=4500000,CODECS="avc1.640028,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=29.970,HDCP-LEVEL=TYPE-0,VIDEO-RANGE=SDR,AUDIO="aac",STABLE-VARIANT-ID="hd-1080",PATHWAY-ID="CDN-A",SCORE=12.5,SUPPLEMENTAL-CODECS="dvh1.05.06/db4g",ALLOWED-CPC="com.example.drm:SMART-TV/PC",REQ-VIDEO-LAYOUT="CH-MONO"
6
+ 1080p.m3u8
7
+ #EXT-X-STREAM-INF:BANDWIDTH=2000000,AVERAGE-BANDWIDTH=1800000,CODECS="avc1.4d001f,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=29.970,VIDEO-RANGE=SDR,AUDIO="aac",STABLE-VARIANT-ID="hd-720"
8
+ 720p.m3u8
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe M3u8::BitrateItem do
6
+ describe '.new' do
7
+ it 'assigns attributes from options' do
8
+ item = described_class.new(bitrate: 128)
9
+ expect(item.bitrate).to eq(128)
10
+ end
11
+ end
12
+
13
+ describe '.parse' do
14
+ it 'returns instance from parsed tag' do
15
+ item = described_class.parse('#EXT-X-BITRATE:128')
16
+ expect(item.bitrate).to eq(128)
17
+ end
18
+ end
19
+
20
+ describe '#to_s' do
21
+ it 'returns m3u8 format representation' do
22
+ item = described_class.new(bitrate: 128)
23
+ expect(item.to_s).to eq('#EXT-X-BITRATE:128')
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe M3u8::Builder do
6
+ shared_examples 'a builder method' do |method, klass, params, checks|
7
+ it "adds a #{klass} to the playlist" do
8
+ pl = M3u8::Playlist.build { send(method, **params) }
9
+
10
+ item = pl.items.first
11
+ expect(item).to be_a(klass)
12
+ checks.each do |attr, value|
13
+ expect(item.public_send(attr)).to eq(value)
14
+ end
15
+ end
16
+ end
17
+
18
+ shared_examples 'a zero-arg builder method' do |method, klass|
19
+ it "adds a #{klass} to the playlist" do
20
+ pl = M3u8::Playlist.build { send(method) }
21
+ expect(pl.items.first).to be_a(klass)
22
+ end
23
+ end
24
+
25
+ describe '#segment' do
26
+ include_examples 'a builder method',
27
+ :segment, M3u8::SegmentItem,
28
+ { duration: 11.34,
29
+ segment: '1080-7mbps00000.ts' },
30
+ { duration: 11.34,
31
+ segment: '1080-7mbps00000.ts' }
32
+ end
33
+
34
+ describe '#playlist' do
35
+ include_examples 'a builder method',
36
+ :playlist, M3u8::PlaylistItem,
37
+ { bandwidth: 540, uri: 'test.url' },
38
+ { bandwidth: 540, uri: 'test.url' }
39
+ end
40
+
41
+ describe '#media' do
42
+ include_examples 'a builder method',
43
+ :media, M3u8::MediaItem,
44
+ { type: 'AUDIO', group_id: 'audio-lo',
45
+ name: 'English', default: true,
46
+ uri: 'eng/prog_index.m3u8' },
47
+ { type: 'AUDIO', group_id: 'audio-lo',
48
+ name: 'English' }
49
+ end
50
+
51
+ describe '#session_data' do
52
+ include_examples 'a builder method',
53
+ :session_data, M3u8::SessionDataItem,
54
+ { data_id: 'com.example.title',
55
+ value: 'My Video', language: 'en' },
56
+ { data_id: 'com.example.title',
57
+ value: 'My Video' }
58
+ end
59
+
60
+ describe '#session_key' do
61
+ include_examples 'a builder method',
62
+ :session_key, M3u8::SessionKeyItem,
63
+ { method: 'AES-128',
64
+ uri: 'https://example.com/key.bin' },
65
+ { method: 'AES-128' }
66
+ end
67
+
68
+ describe '#content_steering' do
69
+ include_examples 'a builder method',
70
+ :content_steering,
71
+ M3u8::ContentSteeringItem,
72
+ { server_uri: 'https://example.com/s',
73
+ pathway_id: 'CDN-A' },
74
+ { server_uri: 'https://example.com/s',
75
+ pathway_id: 'CDN-A' }
76
+ end
77
+
78
+ describe '#key' do
79
+ include_examples 'a builder method',
80
+ :key, M3u8::KeyItem,
81
+ { method: 'AES-128',
82
+ uri: 'https://example.com/key.bin' },
83
+ { method: 'AES-128' }
84
+ end
85
+
86
+ describe '#map' do
87
+ it 'adds a MapItem to the playlist' do
88
+ pl = M3u8::Playlist.build do
89
+ map uri: 'init.mp4',
90
+ byterange: { length: 812, start: 0 }
91
+ end
92
+
93
+ item = pl.items.first
94
+ expect(item).to be_a(M3u8::MapItem)
95
+ expect(item.uri).to eq('init.mp4')
96
+ expect(item.byterange.length).to eq(812)
97
+ end
98
+ end
99
+
100
+ describe '#date_range' do
101
+ include_examples 'a builder method',
102
+ :date_range, M3u8::DateRangeItem,
103
+ { id: 'ad-break-1',
104
+ start_date: '2024-06-01T12:00:00Z',
105
+ planned_duration: 30.0 },
106
+ { id: 'ad-break-1',
107
+ planned_duration: 30.0 }
108
+ end
109
+
110
+ describe '#discontinuity' do
111
+ include_examples 'a zero-arg builder method',
112
+ :discontinuity, M3u8::DiscontinuityItem
113
+ end
114
+
115
+ describe '#gap' do
116
+ include_examples 'a zero-arg builder method',
117
+ :gap, M3u8::GapItem
118
+ end
119
+
120
+ describe '#time' do
121
+ include_examples 'a builder method',
122
+ :time, M3u8::TimeItem,
123
+ { time: '2024-06-01T12:00:00Z' },
124
+ { time: '2024-06-01T12:00:00Z' }
125
+ end
126
+
127
+ describe '#bitrate' do
128
+ include_examples 'a builder method',
129
+ :bitrate, M3u8::BitrateItem,
130
+ { bitrate: 1500 },
131
+ { bitrate: 1500 }
132
+ end
133
+
134
+ describe '#part' do
135
+ include_examples 'a builder method',
136
+ :part, M3u8::PartItem,
137
+ { duration: 0.5, uri: 'seg101.0.mp4',
138
+ independent: true },
139
+ { duration: 0.5, uri: 'seg101.0.mp4' }
140
+ end
141
+
142
+ describe '#preload_hint' do
143
+ include_examples 'a builder method',
144
+ :preload_hint, M3u8::PreloadHintItem,
145
+ { type: 'PART', uri: 'seg101.1.mp4' },
146
+ { type: 'PART', uri: 'seg101.1.mp4' }
147
+ end
148
+
149
+ describe '#rendition_report' do
150
+ include_examples 'a builder method',
151
+ :rendition_report,
152
+ M3u8::RenditionReportItem,
153
+ { uri: '../alt/index.m3u8',
154
+ last_msn: 101, last_part: 0 },
155
+ { uri: '../alt/index.m3u8',
156
+ last_msn: 101 }
157
+ end
158
+
159
+ describe '#skip' do
160
+ include_examples 'a builder method',
161
+ :skip, M3u8::SkipItem,
162
+ { skipped_segments: 10 },
163
+ { skipped_segments: 10 }
164
+ end
165
+
166
+ describe '#define' do
167
+ include_examples 'a builder method',
168
+ :define, M3u8::DefineItem,
169
+ { name: 'base',
170
+ value: 'https://example.com' },
171
+ { name: 'base',
172
+ value: 'https://example.com' }
173
+ end
174
+
175
+ describe '#playback_start' do
176
+ include_examples 'a builder method',
177
+ :playback_start, M3u8::PlaybackStart,
178
+ { time_offset: 10.0, precise: true },
179
+ { time_offset: 10.0 }
180
+ end
181
+
182
+ describe 'Playlist.build' do
183
+ it 'returns a Playlist with options' do
184
+ playlist = M3u8::Playlist.build(version: 4,
185
+ target: 12) do
186
+ segment duration: 11.34, segment: 'test.ts'
187
+ end
188
+
189
+ expect(playlist).to be_a(M3u8::Playlist)
190
+ expect(playlist.version).to eq(4)
191
+ expect(playlist.target).to eq(12)
192
+ end
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
+
208
+ it 'supports yielded builder form' do
209
+ files = %w[seg1.ts seg2.ts]
210
+ playlist = M3u8::Playlist.build(version: 4) do |b|
211
+ files.each do |f|
212
+ b.segment duration: 10.0, segment: f
213
+ end
214
+ end
215
+
216
+ expect(playlist.items.size).to eq(2)
217
+ expect(playlist.items[0].segment).to eq('seg1.ts')
218
+ expect(playlist.items[1].segment).to eq('seg2.ts')
219
+ end
220
+ end
221
+
222
+ describe 'integration' do
223
+ it 'produces identical output to imperative API ' \
224
+ 'for a master playlist' do
225
+ imperative = M3u8::Playlist.new(
226
+ independent_segments: true
227
+ )
228
+ imperative.items << M3u8::PlaylistItem.new(
229
+ program_id: '1', bandwidth: 6400,
230
+ audio_codec: 'mp3', uri: 'lo/index.m3u8'
231
+ )
232
+ imperative.items << M3u8::PlaylistItem.new(
233
+ program_id: '2', bandwidth: 50_000,
234
+ width: 1920, height: 1080,
235
+ profile: 'high', level: 4.1,
236
+ audio_codec: 'aac-lc', uri: 'hi/index.m3u8'
237
+ )
238
+ imperative.items << M3u8::SessionDataItem.new(
239
+ data_id: 'com.test.title', value: 'Test',
240
+ language: 'en'
241
+ )
242
+
243
+ built = M3u8::Playlist.build(
244
+ independent_segments: true
245
+ ) do
246
+ playlist program_id: '1', bandwidth: 6400,
247
+ audio_codec: 'mp3', uri: 'lo/index.m3u8'
248
+ playlist program_id: '2', bandwidth: 50_000,
249
+ width: 1920, height: 1080,
250
+ profile: 'high', level: 4.1,
251
+ audio_codec: 'aac-lc',
252
+ uri: 'hi/index.m3u8'
253
+ session_data data_id: 'com.test.title',
254
+ value: 'Test', language: 'en'
255
+ end
256
+
257
+ expect(built.to_s).to eq(imperative.to_s)
258
+ end
259
+
260
+ it 'produces identical output to imperative API ' \
261
+ 'for a media playlist' do
262
+ imperative = M3u8::Playlist.new(
263
+ version: 7, cache: false, target: 12,
264
+ sequence: 1, type: 'VOD'
265
+ )
266
+ imperative.items << M3u8::KeyItem.new(
267
+ method: 'AES-128', uri: 'http://test.key',
268
+ iv: 'D512BBF', key_format: 'identity',
269
+ key_format_versions: '1/3'
270
+ )
271
+ imperative.items << M3u8::SegmentItem.new(
272
+ duration: 11.344644, segment: '00000.ts'
273
+ )
274
+ imperative.items << M3u8::DiscontinuityItem.new
275
+ imperative.items << M3u8::TimeItem.new(
276
+ time: '2024-06-01T12:00:00Z'
277
+ )
278
+ imperative.items << M3u8::SegmentItem.new(
279
+ duration: 11.261233, segment: '00001.ts'
280
+ )
281
+ imperative.items << M3u8::MapItem.new(
282
+ uri: 'init.mp4',
283
+ byterange: { length: 812, start: 0 }
284
+ )
285
+ imperative.items << M3u8::SegmentItem.new(
286
+ duration: 7.5, segment: '00002.ts'
287
+ )
288
+
289
+ built = M3u8::Playlist.build(
290
+ version: 7, cache: false, target: 12,
291
+ sequence: 1, type: 'VOD'
292
+ ) do
293
+ key method: 'AES-128', uri: 'http://test.key',
294
+ iv: 'D512BBF', key_format: 'identity',
295
+ key_format_versions: '1/3'
296
+ segment duration: 11.344644, segment: '00000.ts'
297
+ discontinuity
298
+ time time: '2024-06-01T12:00:00Z'
299
+ segment duration: 11.261233, segment: '00001.ts'
300
+ map uri: 'init.mp4',
301
+ byterange: { length: 812, start: 0 }
302
+ segment duration: 7.5, segment: '00002.ts'
303
+ end
304
+
305
+ expect(built.to_s).to eq(imperative.to_s)
306
+ end
307
+
308
+ it 'produces identical output to imperative API ' \
309
+ 'for an LL-HLS playlist' do
310
+ sc = M3u8::ServerControlItem.new(
311
+ can_skip_until: 24.0, part_hold_back: 1.0,
312
+ can_block_reload: true
313
+ )
314
+ pi = M3u8::PartInfItem.new(part_target: 0.5)
315
+ opts = {
316
+ version: 9, target: 4, sequence: 100,
317
+ server_control: sc, part_inf: pi, live: true
318
+ }
319
+
320
+ imperative = M3u8::Playlist.new(opts)
321
+ imperative.items << M3u8::MapItem.new(
322
+ uri: 'init.mp4'
323
+ )
324
+ imperative.items << M3u8::SegmentItem.new(
325
+ duration: 4.0, segment: 'seg100.mp4'
326
+ )
327
+ imperative.items << M3u8::PartItem.new(
328
+ duration: 0.5, uri: 'seg101.0.mp4',
329
+ independent: true
330
+ )
331
+ imperative.items << M3u8::PreloadHintItem.new(
332
+ type: 'PART', uri: 'seg101.1.mp4'
333
+ )
334
+ imperative.items << M3u8::RenditionReportItem.new(
335
+ uri: '../alt/index.m3u8',
336
+ last_msn: 101, last_part: 0
337
+ )
338
+
339
+ built = M3u8::Playlist.build(opts) do
340
+ map uri: 'init.mp4'
341
+ segment duration: 4.0, segment: 'seg100.mp4'
342
+ part duration: 0.5, uri: 'seg101.0.mp4',
343
+ independent: true
344
+ preload_hint type: 'PART', uri: 'seg101.1.mp4'
345
+ rendition_report uri: '../alt/index.m3u8',
346
+ last_msn: 101, last_part: 0
347
+ end
348
+
349
+ expect(built.to_s).to eq(imperative.to_s)
350
+ end
351
+ end
352
+ end