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,14 +1,45 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module M3u8
3
4
  # PlaylistItem represents a set of EXT-X-STREAM-INF or
4
5
  # EXT-X-I-FRAME-STREAM-INF attributes
5
6
  class PlaylistItem
6
- include M3u8
7
+ extend M3u8
8
+ include AttributeFormatter
9
+
10
+ # @return [String, nil] program ID
11
+ # @return [Integer, nil] horizontal resolution in pixels
12
+ # @return [Integer, nil] vertical resolution in pixels
13
+ # @return [String, nil] codec string or computed codecs
14
+ # @return [Integer, nil] peak bandwidth in bits/s
15
+ # @return [String, nil] audio codec hint for codecs generation
16
+ # @return [String, Integer, Float, nil] H.264/HEVC/AV1 level
17
+ # @return [String, nil] H.264/HEVC/AV1 profile name
18
+ # @return [String, nil] VIDEO rendition group ID
19
+ # @return [String, nil] AUDIO rendition group ID
20
+ # @return [String, nil] stream URI
21
+ # @return [Integer, nil] average bandwidth in bits/s
22
+ # @return [String, nil] SUBTITLES rendition group ID
23
+ # @return [String, nil] CLOSED-CAPTIONS value or 'NONE'
24
+ # @return [Boolean] whether this is an I-frame stream
25
+ # @return [BigDecimal, nil] frame rate
26
+ # @return [String, nil] stream name
27
+ # @return [String, nil] HDCP level (TYPE-0, TYPE-1, NONE)
28
+ # @return [String, nil] stable variant ID
29
+ # @return [String, nil] video range (SDR, HLG, PQ)
30
+ # @return [String, nil] allowed CPC value
31
+ # @return [String, nil] content steering pathway ID
32
+ # @return [String, nil] required video layout
33
+ # @return [String, nil] supplemental codecs string
34
+ # @return [Float, nil] stream score
7
35
  attr_accessor :program_id, :width, :height, :codecs, :bandwidth,
8
36
  :audio_codec, :level, :profile, :video, :audio, :uri,
9
37
  :average_bandwidth, :subtitles, :closed_captions, :iframe,
10
- :frame_rate, :name, :hdcp_level
38
+ :frame_rate, :name, :hdcp_level, :stable_variant_id,
39
+ :video_range, :allowed_cpc, :pathway_id,
40
+ :req_video_layout, :supplemental_codecs, :score
11
41
 
42
+ # @param params [Hash] attribute key-value pairs
12
43
  def initialize(params = {})
13
44
  self.iframe = false
14
45
  params.each do |key, value|
@@ -16,69 +47,105 @@ module M3u8
16
47
  end
17
48
  end
18
49
 
50
+ # Parse an EXT-X-STREAM-INF or EXT-X-I-FRAME-STREAM-INF tag.
51
+ # @param text [String] raw tag line
52
+ # @return [PlaylistItem]
19
53
  def self.parse(text)
20
- item = PlaylistItem.new
21
- item.parse(text)
22
- item
23
- end
24
-
25
- def parse(text)
26
54
  attributes = parse_attributes(text)
27
55
  options = options_from_attributes(attributes)
28
- initialize(options)
56
+ PlaylistItem.new(options)
29
57
  end
30
58
 
59
+ # Format the resolution as a WIDTHxHEIGHT string.
60
+ # @return [String, nil]
31
61
  def resolution
32
62
  return if width.nil?
63
+
33
64
  "#{width}x#{height}"
34
65
  end
35
66
 
67
+ # Return or compute the codecs string.
68
+ # @return [String, nil]
36
69
  def codecs
37
70
  return @codecs unless @codecs.nil?
38
71
 
39
- video_codec_string = video_codec(profile, level)
72
+ video = Codecs.video_codec(profile, level)
40
73
 
41
- # profile and/or level were specified but not recognized,
42
- # do not specify any codecs
43
- return nil if !(profile.nil? && level.nil?) && video_codec_string.nil?
74
+ # profile and/or level were specified but not recognized
75
+ return nil if !(profile.nil? && level.nil?) && video.nil?
44
76
 
45
- audio_codec_string = audio_codec_code
77
+ audio = Codecs.audio_codec(@audio_codec)
46
78
 
47
- # audio codec was specified but not recognized,
48
- # do not specify any codecs
49
- return nil if !@audio_codec.nil? && audio_codec_string.nil?
79
+ # audio codec was specified but not recognized
80
+ return nil if !@audio_codec.nil? && audio.nil?
50
81
 
51
- codec_strings = [video_codec_string, audio_codec_string].compact
52
- codec_strings.empty? ? nil : codec_strings.join(',')
82
+ strings = [video, audio].compact
83
+ strings.empty? ? nil : strings.join(',')
53
84
  end
54
85
 
86
+ # Render as an m3u8 tag string.
87
+ # @return [String]
55
88
  def to_s
56
89
  m3u8_format
57
90
  end
58
91
 
59
- private
92
+ def self.options_from_attributes(attributes)
93
+ parse_stream_attrs(attributes)
94
+ .merge(parse_media_attrs(attributes))
95
+ end
96
+ private_class_method :options_from_attributes
60
97
 
61
- def options_from_attributes(attributes)
98
+ def self.parse_stream_attrs(attributes)
62
99
  resolution = parse_resolution(attributes['RESOLUTION'])
63
100
  { program_id: attributes['PROGRAM-ID'],
64
101
  codecs: attributes['CODECS'],
65
102
  width: resolution[:width],
66
103
  height: resolution[:height],
67
- bandwidth: attributes['BANDWIDTH'].to_i,
104
+ bandwidth:
105
+ parse_bandwidth(attributes['BANDWIDTH']),
68
106
  average_bandwidth:
69
- parse_average_bandwidth(attributes['AVERAGE-BANDWIDTH']),
70
- frame_rate: parse_frame_rate(attributes['FRAME-RATE']),
71
- video: attributes['VIDEO'], audio: attributes['AUDIO'],
72
- uri: attributes['URI'], subtitles: attributes['SUBTITLES'],
107
+ parse_average_bandwidth(
108
+ attributes['AVERAGE-BANDWIDTH']
109
+ ),
110
+ frame_rate:
111
+ parse_frame_rate(attributes['FRAME-RATE']),
112
+ hdcp_level: attributes['HDCP-LEVEL'],
113
+ video_range: attributes['VIDEO-RANGE'],
114
+ supplemental_codecs:
115
+ attributes['SUPPLEMENTAL-CODECS'],
116
+ score: parse_float(attributes['SCORE']) }
117
+ end
118
+ private_class_method :parse_stream_attrs
119
+
120
+ def self.parse_media_attrs(attributes)
121
+ { video: attributes['VIDEO'],
122
+ audio: attributes['AUDIO'],
123
+ uri: attributes['URI'],
124
+ subtitles: attributes['SUBTITLES'],
73
125
  closed_captions: attributes['CLOSED-CAPTIONS'],
74
- name: attributes['NAME'], hdcp_level: attributes['HDCP-LEVEL'] }
126
+ name: attributes['NAME'],
127
+ stable_variant_id:
128
+ attributes['STABLE-VARIANT-ID'],
129
+ allowed_cpc: attributes['ALLOWED-CPC'],
130
+ pathway_id: attributes['PATHWAY-ID'],
131
+ req_video_layout:
132
+ attributes['REQ-VIDEO-LAYOUT'] }
75
133
  end
134
+ private_class_method :parse_media_attrs
76
135
 
77
- def parse_average_bandwidth(value)
78
- value.to_i unless value.nil?
136
+ def self.parse_average_bandwidth(value)
137
+ value&.to_i
79
138
  end
139
+ private_class_method :parse_average_bandwidth
80
140
 
81
- def parse_resolution(resolution)
141
+ def self.parse_bandwidth(value)
142
+ return if value.nil?
143
+
144
+ value.to_i
145
+ end
146
+ private_class_method :parse_bandwidth
147
+
148
+ def self.parse_resolution(resolution)
82
149
  return { width: nil, height: nil } if resolution.nil?
83
150
 
84
151
  values = resolution.split('x')
@@ -86,13 +153,17 @@ module M3u8
86
153
  height = values[1].to_i
87
154
  { width: width, height: height }
88
155
  end
156
+ private_class_method :parse_resolution
89
157
 
90
- def parse_frame_rate(frame_rate)
158
+ def self.parse_frame_rate(frame_rate)
91
159
  return if frame_rate.nil?
92
160
 
93
161
  value = BigDecimal(frame_rate)
94
- value if value > 0
162
+ value if value.positive?
95
163
  end
164
+ private_class_method :parse_frame_rate
165
+
166
+ private
96
167
 
97
168
  def m3u8_format
98
169
  return %(#EXT-X-I-FRAME-STREAM-INF:#{attributes},URI="#{uri}") if iframe
@@ -101,116 +172,49 @@ module M3u8
101
172
  end
102
173
 
103
174
  def attributes
104
- [program_id_format,
105
- resolution_format,
106
- codecs_format,
107
- bandwidth_format,
108
- average_bandwidth_format,
109
- frame_rate_format,
110
- hdcp_level_format,
111
- audio_format,
112
- video_format,
113
- subtitles_format,
114
- closed_captions_format,
115
- name_format].compact.join(',')
175
+ (stream_attributes + media_attributes).compact.join(',')
116
176
  end
117
177
 
118
- def program_id_format
119
- return if program_id.nil?
120
- "PROGRAM-ID=#{program_id}"
178
+ def stream_attributes
179
+ [unquoted_format('PROGRAM-ID', program_id),
180
+ unquoted_format('RESOLUTION', resolution),
181
+ quoted_format('CODECS', codecs),
182
+ quoted_format('SUPPLEMENTAL-CODECS', supplemental_codecs),
183
+ "BANDWIDTH=#{bandwidth}",
184
+ unquoted_format('AVERAGE-BANDWIDTH', average_bandwidth),
185
+ unquoted_format('SCORE', score),
186
+ frame_rate_format,
187
+ unquoted_format('HDCP-LEVEL', hdcp_level),
188
+ unquoted_format('VIDEO-RANGE', video_range)]
121
189
  end
122
190
 
123
- def resolution_format
124
- return if resolution.nil?
125
- "RESOLUTION=#{resolution}"
191
+ def media_attributes
192
+ [quoted_format('ALLOWED-CPC', allowed_cpc),
193
+ quoted_format('AUDIO', audio),
194
+ quoted_format('VIDEO', video),
195
+ quoted_format('SUBTITLES', subtitles),
196
+ closed_captions_format,
197
+ quoted_format('NAME', name),
198
+ quoted_format('STABLE-VARIANT-ID', stable_variant_id),
199
+ quoted_format('PATHWAY-ID', pathway_id),
200
+ quoted_format('REQ-VIDEO-LAYOUT',
201
+ req_video_layout)]
126
202
  end
127
203
 
128
204
  def frame_rate_format
129
205
  return if frame_rate.nil?
130
- "FRAME-RATE=#{format('%.3f', frame_rate)}"
131
- end
132
-
133
- def hdcp_level_format
134
- return if hdcp_level.nil?
135
- "HDCP-LEVEL=#{hdcp_level}"
136
- end
137
-
138
- def codecs_format
139
- return if codecs.nil?
140
- %(CODECS="#{codecs}")
141
- end
142
-
143
- def bandwidth_format
144
- "BANDWIDTH=#{bandwidth}"
145
- end
146
-
147
- def average_bandwidth_format
148
- return if average_bandwidth.nil?
149
- "AVERAGE-BANDWIDTH=#{average_bandwidth}"
150
- end
151
206
 
152
- def audio_format
153
- return if audio.nil?
154
- %(AUDIO="#{audio}")
155
- end
156
-
157
- def video_format
158
- return if video.nil?
159
- %(VIDEO="#{video}")
160
- end
161
-
162
- def subtitles_format
163
- return if subtitles.nil?
164
- %(SUBTITLES="#{subtitles}")
207
+ "FRAME-RATE=#{format('%.3f', frame_rate)}"
165
208
  end
166
209
 
167
210
  def closed_captions_format
168
211
  return if closed_captions.nil?
169
212
 
170
213
  if closed_captions == 'NONE'
171
- %(CLOSED-CAPTIONS=NONE)
214
+ 'CLOSED-CAPTIONS=NONE'
172
215
  else
173
216
  %(CLOSED-CAPTIONS="#{closed_captions}")
174
217
  end
175
218
  end
176
-
177
- def name_format
178
- return if name.nil?
179
- %(NAME="#{name}")
180
- end
181
-
182
- def audio_codec_code
183
- return if @audio_codec.nil?
184
- return 'mp4a.40.2' if @audio_codec.casecmp('aac-lc').zero?
185
- return 'mp4a.40.5' if @audio_codec.casecmp('he-aac').zero?
186
- return 'mp4a.40.34' if @audio_codec.casecmp('mp3').zero?
187
- end
188
-
189
- def video_codec(profile, level)
190
- return if profile.nil? || level.nil?
191
-
192
- return baseline_codec_string(level) if profile.casecmp('baseline').zero?
193
- return main_codec_string(level) if profile.casecmp('main').zero?
194
- return high_codec_string(level) if profile.casecmp('high').zero?
195
- end
196
-
197
- def baseline_codec_string(level)
198
- return 'avc1.66.30' if level == 3.0
199
- return 'avc1.42001f' if level == 3.1
200
- end
201
-
202
- def main_codec_string(level)
203
- return 'avc1.77.30' if level == 3.0
204
- return 'avc1.4d001f' if level == 3.1
205
- return 'avc1.4d0028' if level == 4.0
206
- return 'avc1.4d0029' if level == 4.1
207
- end
208
-
209
- def high_codec_string(level)
210
- return nil unless [3.0, 3.1, 3.2, 4.0, 4.1, 4.2, 5.0, 5.1, 5.2].include?(level)
211
-
212
- level_hex_string = level.to_s.sub('.', '').to_i.to_s(16)
213
- return "avc1.6400#{level_hex_string}"
214
- end
215
219
  end
216
220
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # PreloadHintItem represents an EXT-X-PRELOAD-HINT tag which allows
5
+ # a server to indicate a resource that will be needed soon.
6
+ class PreloadHintItem
7
+ extend M3u8
8
+ include AttributeFormatter
9
+
10
+ # @return [String, nil] hint type (PART or MAP)
11
+ # @return [String, nil] hint resource URI
12
+ # @return [Integer, nil] byte range start offset
13
+ # @return [Integer, nil] byte range length
14
+ attr_accessor :type, :uri, :byterange_start, :byterange_length
15
+
16
+ # @param params [Hash] attribute key-value pairs
17
+ def initialize(params = {})
18
+ params.each do |key, value|
19
+ instance_variable_set("@#{key}", value)
20
+ end
21
+ end
22
+
23
+ # Parse an EXT-X-PRELOAD-HINT tag.
24
+ # @param text [String] raw tag line
25
+ # @return [PreloadHintItem]
26
+ def self.parse(text)
27
+ attributes = parse_attributes(text)
28
+ PreloadHintItem.new(
29
+ type: attributes['TYPE'],
30
+ uri: attributes['URI'],
31
+ byterange_start:
32
+ parse_int(attributes['BYTERANGE-START']),
33
+ byterange_length:
34
+ parse_int(attributes['BYTERANGE-LENGTH'])
35
+ )
36
+ end
37
+
38
+ # Render as an m3u8 EXT-X-PRELOAD-HINT tag.
39
+ # @return [String]
40
+ def to_s
41
+ "#EXT-X-PRELOAD-HINT:#{formatted_attributes}"
42
+ end
43
+
44
+ private
45
+
46
+ def formatted_attributes
47
+ [unquoted_format('TYPE', type),
48
+ quoted_format('URI', uri),
49
+ unquoted_format('BYTERANGE-START', byterange_start),
50
+ unquoted_format('BYTERANGE-LENGTH',
51
+ byterange_length)].compact.join(',')
52
+ end
53
+ end
54
+ end
data/lib/m3u8/reader.rb CHANGED
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module M3u8
3
4
  # Reader provides parsing of m3u8 playlists
4
5
  class Reader
5
- include M3u8
6
6
  attr_accessor :playlist, :item, :open, :master, :tags
7
7
 
8
8
  def initialize(*)
9
+ @has_endlist = false
9
10
  @tags = [basic_tags,
10
11
  media_segment_tags,
11
12
  media_playlist_tags,
@@ -13,13 +14,19 @@ module M3u8
13
14
  universal_tags].inject(:merge)
14
15
  end
15
16
 
17
+ # Parse an m3u8 playlist from a String or IO.
18
+ # @param input [String, IO] playlist content
19
+ # @return [Playlist] frozen playlist
16
20
  def read(input)
17
21
  @playlist = Playlist.new
18
- input.each_line.with_index do |line, index|
22
+ @has_endlist = false
23
+ lines = input.is_a?(String) ? input : input.read
24
+ lines.split(/\r\n|\r|\n/).each_with_index do |line, index|
19
25
  validate_file_format(line) if index.zero?
20
26
  parse_line(line)
21
27
  end
22
- playlist
28
+ playlist.live = !@has_endlist unless master
29
+ playlist.freeze
23
30
  end
24
31
 
25
32
  private
@@ -36,7 +43,10 @@ module M3u8
36
43
  '#EXT-X-KEY' => ->(line) { parse_key(line) },
37
44
  '#EXT-X-MAP' => ->(line) { parse_map(line) },
38
45
  '#EXT-X-PROGRAM-DATE-TIME' => ->(line) { parse_time(line) },
39
- '#EXT-X-DATERANGE' => ->(line) { parse_date_range(line) }
46
+ '#EXT-X-DATERANGE' => ->(line) { parse_date_range(line) },
47
+ '#EXT-X-GAP' => ->(_line) { parse_gap },
48
+ '#EXT-X-BITRATE' => ->(line) { parse_bitrate(line) },
49
+ '#EXT-X-PART' => ->(line) { parse_part(line) }
40
50
  }
41
51
  end
42
52
 
@@ -48,8 +58,14 @@ module M3u8
48
58
  end,
49
59
  '#EXT-X-ALLOW-CACHE' => ->(line) { parse_cache(line) },
50
60
  '#EXT-X-TARGETDURATION' => ->(line) { parse_target(line) },
51
- '#EXT-X-I-FRAMES-ONLY' => proc { playlist.iframes_only = true },
52
- '#EXT-X-PLAYLIST-TYPE' => ->(line) { parse_playlist_type(line) }
61
+ '#EXT-X-I-FRAMES-ONLY' => ->(_line) { playlist.iframes_only = true },
62
+ '#EXT-X-PLAYLIST-TYPE' => ->(line) { parse_playlist_type(line) },
63
+ '#EXT-X-PART-INF' => ->(line) { parse_part_inf(line) },
64
+ '#EXT-X-SERVER-CONTROL' => ->(line) { parse_server_control(line) },
65
+ '#EXT-X-SKIP' => ->(line) { parse_skip(line) },
66
+ '#EXT-X-PRELOAD-HINT' => ->(line) { parse_preload_hint(line) },
67
+ '#EXT-X-RENDITION-REPORT' => ->(line) { parse_rendition_report(line) },
68
+ '#EXT-X-ENDLIST' => ->(_line) { @has_endlist = true }
53
69
  }
54
70
  end
55
71
 
@@ -59,21 +75,24 @@ module M3u8
59
75
  '#EXT-X-SESSION-DATA' => ->(line) { parse_session_data(line) },
60
76
  '#EXT-X-SESSION-KEY' => ->(line) { parse_session_key(line) },
61
77
  '#EXT-X-STREAM-INF' => ->(line) { parse_stream(line) },
62
- '#EXT-X-I-FRAME-STREAM-INF' => ->(line) { parse_iframe_stream(line) }
78
+ '#EXT-X-I-FRAME-STREAM-INF' => ->(line) { parse_iframe_stream(line) },
79
+ '#EXT-X-CONTENT-STEERING' => ->(line) { parse_content_steering(line) }
63
80
  }
64
81
  end
65
82
 
66
83
  def universal_tags
67
84
  {
68
85
  '#EXT-X-START' => ->(line) { parse_start(line) },
69
- '#EXT-X-INDEPENDENT-SEGMENTS' => proc do
86
+ '#EXT-X-INDEPENDENT-SEGMENTS' => lambda do |_line|
70
87
  playlist.independent_segments = true
71
- end
88
+ end,
89
+ '#EXT-X-DEFINE' => ->(line) { parse_define(line) }
72
90
  }
73
91
  end
74
92
 
75
93
  def parse_line(line)
76
94
  return if match_tag(line)
95
+
77
96
  parse_next_line(line) if !item.nil? && open
78
97
  end
79
98
 
@@ -83,29 +102,33 @@ module M3u8
83
102
  end
84
103
 
85
104
  return unless tag.values.first
105
+
86
106
  tag.values.first.call(line)
87
107
  true
88
108
  end
89
109
 
110
+ def tag_value(line)
111
+ line.split(':', 2).last.strip
112
+ end
113
+
90
114
  def parse_playlist_type(line)
91
- playlist.type = line.gsub('#EXT-X-PLAYLIST-TYPE:', '').delete!("\n")
115
+ playlist.type = tag_value(line)
92
116
  end
93
117
 
94
118
  def parse_version(line)
95
- playlist.version = line.gsub('#EXT-X-VERSION:', '').to_i
119
+ playlist.version = tag_value(line).to_i
96
120
  end
97
121
 
98
122
  def parse_sequence(line)
99
- playlist.sequence = line.gsub('#EXT-X-MEDIA-SEQUENCE:', '').to_i
123
+ playlist.sequence = tag_value(line).to_i
100
124
  end
101
125
 
102
126
  def parse_cache(line)
103
- line = line.gsub('#EXT-X-ALLOW-CACHE:', '')
104
- playlist.cache = parse_yes_no(line)
127
+ playlist.cache = tag_value(line) == 'YES'
105
128
  end
106
129
 
107
130
  def parse_target(line)
108
- playlist.target = line.gsub('#EXT-X-TARGETDURATION:', '').to_i
131
+ playlist.target = tag_value(line).to_i
109
132
  end
110
133
 
111
134
  def parse_stream(line)
@@ -133,8 +156,7 @@ module M3u8
133
156
  end
134
157
 
135
158
  def parse_discontinuity_sequence(line)
136
- value = line.gsub('#EXT-X-DISCONTINUITY-SEQUENCE:', '').strip
137
- playlist.discontinuity_sequence = Integer(value)
159
+ playlist.discontinuity_sequence = Integer(tag_value(line))
138
160
  end
139
161
 
140
162
  def parse_key(line)
@@ -148,11 +170,7 @@ module M3u8
148
170
  end
149
171
 
150
172
  def parse_segment(line)
151
- self.item = M3u8::SegmentItem.new
152
- values = line.gsub('#EXTINF:', '').tr("\n", ',').split(',')
153
- item.duration = values[0].to_f
154
- item.comment = values[1] unless values[1].nil?
155
-
173
+ self.item = M3u8::SegmentItem.parse(line)
156
174
  self.master = false
157
175
  self.open = true
158
176
  end
@@ -179,9 +197,7 @@ module M3u8
179
197
  end
180
198
 
181
199
  def parse_start(line)
182
- item = M3u8::PlaybackStart.new
183
- item.parse(line)
184
- playlist.items << item
200
+ playlist.items << M3u8::PlaybackStart.parse(line)
185
201
  end
186
202
 
187
203
  def parse_time(line)
@@ -194,9 +210,50 @@ module M3u8
194
210
  end
195
211
 
196
212
  def parse_date_range(line)
197
- item = M3u8::DateRangeItem.new
198
- item.parse(line)
199
- playlist.items << item
213
+ playlist.items << M3u8::DateRangeItem.parse(line)
214
+ end
215
+
216
+ def parse_define(line)
217
+ playlist.items << M3u8::DefineItem.parse(line)
218
+ end
219
+
220
+ def parse_content_steering(line)
221
+ playlist.items << M3u8::ContentSteeringItem.parse(line)
222
+ end
223
+
224
+ def parse_part(line)
225
+ self.open = false
226
+ playlist.items << M3u8::PartItem.parse(line)
227
+ end
228
+
229
+ def parse_part_inf(line)
230
+ playlist.part_inf = M3u8::PartInfItem.parse(line)
231
+ end
232
+
233
+ def parse_server_control(line)
234
+ playlist.server_control = M3u8::ServerControlItem.parse(line)
235
+ end
236
+
237
+ def parse_skip(line)
238
+ playlist.items << M3u8::SkipItem.parse(line)
239
+ end
240
+
241
+ def parse_preload_hint(line)
242
+ playlist.items << M3u8::PreloadHintItem.parse(line)
243
+ end
244
+
245
+ def parse_rendition_report(line)
246
+ playlist.items << M3u8::RenditionReportItem.parse(line)
247
+ end
248
+
249
+ def parse_gap(*)
250
+ self.open = false
251
+ playlist.items << M3u8::GapItem.new
252
+ end
253
+
254
+ def parse_bitrate(line)
255
+ self.open = false
256
+ playlist.items << M3u8::BitrateItem.parse(line)
200
257
  end
201
258
 
202
259
  def parse_next_line(line)
@@ -212,6 +269,7 @@ module M3u8
212
269
 
213
270
  def validate_file_format(line)
214
271
  return if line.rstrip == '#EXTM3U'
272
+
215
273
  message = 'Playlist must start with a #EXTM3U tag, line read ' \
216
274
  "contained the value: #{line}"
217
275
  raise InvalidPlaylistError, message
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # RenditionReportItem represents an EXT-X-RENDITION-REPORT tag which
5
+ # carries information about associated renditions in LL-HLS.
6
+ class RenditionReportItem
7
+ extend M3u8
8
+ include AttributeFormatter
9
+
10
+ # @return [String, nil] rendition URI
11
+ # @return [Integer, nil] last media sequence number
12
+ # @return [Integer, nil] last partial segment index
13
+ attr_accessor :uri, :last_msn, :last_part
14
+
15
+ # @param params [Hash] attribute key-value pairs
16
+ def initialize(params = {})
17
+ params.each do |key, value|
18
+ instance_variable_set("@#{key}", value)
19
+ end
20
+ end
21
+
22
+ # Parse an EXT-X-RENDITION-REPORT tag.
23
+ # @param text [String] raw tag line
24
+ # @return [RenditionReportItem]
25
+ def self.parse(text)
26
+ attributes = parse_attributes(text)
27
+ RenditionReportItem.new(
28
+ uri: attributes['URI'],
29
+ last_msn: parse_int(attributes['LAST-MSN']),
30
+ last_part: parse_int(attributes['LAST-PART'])
31
+ )
32
+ end
33
+
34
+ # Render as an m3u8 EXT-X-RENDITION-REPORT tag.
35
+ # @return [String]
36
+ def to_s
37
+ "#EXT-X-RENDITION-REPORT:#{formatted_attributes}"
38
+ end
39
+
40
+ private
41
+
42
+ def formatted_attributes
43
+ [quoted_format('URI', uri),
44
+ unquoted_format('LAST-MSN', last_msn),
45
+ unquoted_format('LAST-PART', last_part)].compact.join(',')
46
+ end
47
+ end
48
+ end