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
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # PartItem represents an EXT-X-PART tag for Low-Latency HLS partial
5
+ # segments.
6
+ class PartItem
7
+ extend M3u8
8
+ include M3u8
9
+ include AttributeFormatter
10
+
11
+ # @return [String, nil] partial segment URI
12
+ # @return [Float, nil] partial segment duration
13
+ # @return [Boolean, nil] whether segment is independent
14
+ # @return [ByteRange, nil] byte range
15
+ # @return [Boolean, nil] whether segment is a gap
16
+ attr_accessor :uri, :duration, :independent, :byterange, :gap
17
+
18
+ # @param params [Hash] attribute key-value pairs
19
+ def initialize(params = {})
20
+ initialize_with_byterange(params)
21
+ end
22
+
23
+ # Parse an EXT-X-PART tag.
24
+ # @param text [String] raw tag line
25
+ # @return [PartItem]
26
+ def self.parse(text)
27
+ attributes = parse_attributes(text)
28
+ range_value = attributes['BYTERANGE']
29
+ range = ByteRange.parse(range_value) unless range_value.nil?
30
+ PartItem.new(
31
+ uri: attributes['URI'],
32
+ duration: attributes['DURATION'].to_f,
33
+ independent: parse_yes_no(attributes['INDEPENDENT']),
34
+ byterange: range,
35
+ gap: parse_yes_no(attributes['GAP'])
36
+ )
37
+ end
38
+
39
+ # Render as an m3u8 EXT-X-PART tag.
40
+ # @return [String]
41
+ def to_s
42
+ "#EXT-X-PART:#{formatted_attributes}"
43
+ end
44
+
45
+ private
46
+
47
+ def formatted_attributes
48
+ [unquoted_format('DURATION', decimal_format(duration)),
49
+ quoted_format('URI', uri),
50
+ independent_format,
51
+ quoted_format('BYTERANGE', byterange),
52
+ gap_format].compact.join(',')
53
+ end
54
+
55
+ def independent_format
56
+ return unless independent
57
+
58
+ 'INDEPENDENT=YES'
59
+ end
60
+
61
+ def gap_format
62
+ return unless gap
63
+
64
+ 'GAP=YES'
65
+ end
66
+ end
67
+ end
@@ -1,34 +1,41 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module M3u8
3
4
  # PlaybackStart represents a #EXT-X-START tag and attributes
4
5
  class PlaybackStart
5
- include M3u8
6
+ extend M3u8
7
+ include AttributeFormatter
8
+
9
+ # @return [Float, nil] time offset in seconds
10
+ # @return [Boolean, nil] whether start is precise
6
11
  attr_accessor :time_offset, :precise
7
12
 
13
+ # @param options [Hash] attribute key-value pairs
8
14
  def initialize(options = {})
9
15
  options.each do |key, value|
10
16
  instance_variable_set("@#{key}", value)
11
17
  end
12
18
  end
13
19
 
14
- def parse(text)
20
+ # Parse an EXT-X-START tag.
21
+ # @param text [String] raw tag line
22
+ # @return [PlaybackStart]
23
+ def self.parse(text)
15
24
  attributes = parse_attributes(text)
16
- @time_offset = attributes['TIME-OFFSET'].to_f
17
25
  precise = attributes['PRECISE']
18
- @precise = parse_yes_no(precise) unless precise.nil?
26
+ options = {
27
+ time_offset: attributes['TIME-OFFSET'].to_f,
28
+ precise: precise.nil? ? nil : parse_yes_no(precise)
29
+ }
30
+ PlaybackStart.new(options)
19
31
  end
20
32
 
33
+ # Render as an m3u8 EXT-X-START tag.
34
+ # @return [String]
21
35
  def to_s
22
36
  attributes = ["TIME-OFFSET=#{time_offset}",
23
- precise_format].compact.join(',')
37
+ boolean_format('PRECISE', precise)].compact.join(',')
24
38
  "#EXT-X-START:#{attributes}"
25
39
  end
26
-
27
- private
28
-
29
- def precise_format
30
- return if precise.nil?
31
- "PRECISE=#{to_yes_no(precise)}"
32
- end
33
40
  end
34
41
  end
data/lib/m3u8/playlist.rb CHANGED
@@ -1,64 +1,188 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module M3u8
3
- # Playlist represents an m3u8 playlist, it can be a master playlist or a set
4
- # of media segments
4
+ # Playlist represents an m3u8 playlist, it can be a master playlist
5
+ # or a set of media segments
5
6
  class Playlist
7
+ # @return [Array] list of items in the playlist
8
+ # @return [Integer, nil] EXT-X-VERSION value
9
+ # @return [Boolean, nil] EXT-X-ALLOW-CACHE value
10
+ # @return [Integer] EXT-X-TARGETDURATION value
11
+ # @return [Integer] EXT-X-MEDIA-SEQUENCE value
12
+ # @return [Integer, nil] EXT-X-DISCONTINUITY-SEQUENCE value
13
+ # @return [String, nil] EXT-X-PLAYLIST-TYPE (VOD or EVENT)
14
+ # @return [Boolean] whether playlist is I-frames only
15
+ # @return [Boolean] whether segments are independent
16
+ # @return [Boolean] whether playlist is live
17
+ # @return [PartInfItem, nil] EXT-X-PART-INF item
18
+ # @return [ServerControlItem, nil] EXT-X-SERVER-CONTROL item
6
19
  attr_accessor :items, :version, :cache, :target, :sequence,
7
20
  :discontinuity_sequence, :type, :iframes_only,
8
- :independent_segments, :live
21
+ :independent_segments, :live, :part_inf,
22
+ :server_control
9
23
 
24
+ # @param options [Hash] playlist attributes
10
25
  def initialize(options = {})
11
26
  assign_options(options)
12
27
  @items = []
13
28
  end
14
29
 
30
+ # Build a playlist using a DSL block.
31
+ # @param options [Hash] playlist attributes
32
+ # @yield [Builder] block receives builder instance
33
+ # @return [Playlist] frozen playlist
34
+ def self.build(options = {}, &block)
35
+ playlist = new(options)
36
+ builder = Builder.new(playlist)
37
+ if block.arity == 1
38
+ yield builder
39
+ else
40
+ builder.instance_eval(&block)
41
+ end
42
+ playlist.freeze
43
+ end
44
+
45
+ # Generate a codecs string from codec options.
46
+ # @param options [Hash] codec options (:profile, :level, etc.)
47
+ # @return [String, nil] codecs string
15
48
  def self.codecs(options = {})
16
49
  item = PlaylistItem.new(options)
17
50
  item.codecs
18
51
  end
19
52
 
53
+ # Parse an m3u8 playlist from a String or IO.
54
+ # @param input [String, IO] playlist content
55
+ # @return [Playlist] frozen playlist
20
56
  def self.read(input)
21
57
  reader = Reader.new
22
58
  reader.read(input)
23
59
  end
24
60
 
61
+ # Write the playlist to an IO object.
62
+ # @param output [IO] writable IO object
63
+ # @return [void]
25
64
  def write(output)
26
65
  writer = Writer.new(output)
27
66
  writer.write(self)
28
67
  end
29
68
 
69
+ # Whether this is a live (non-VOD) media playlist.
70
+ # @return [Boolean]
30
71
  def live?
31
72
  return false if master?
73
+
32
74
  @live
33
75
  end
34
76
 
77
+ # Whether this is a master (multivariant) playlist.
78
+ # @return [Boolean]
35
79
  def master?
36
80
  return @master unless @master.nil?
37
81
  return false if playlist_size.zero? && segment_size.zero?
38
- playlist_size > 0
82
+
83
+ playlist_size.positive?
84
+ end
85
+
86
+ # Freeze the playlist and all its items.
87
+ # @return [Playlist]
88
+ def freeze
89
+ items.each { |item| freeze_item(item) }
90
+ items.freeze
91
+ part_inf&.freeze
92
+ server_control&.freeze
93
+ super
39
94
  end
40
95
 
96
+ # Render the playlist as an m3u8 string.
97
+ # @return [String]
41
98
  def to_s
42
99
  output = StringIO.open
43
100
  write(output)
44
101
  output.string
45
102
  end
46
103
 
104
+ # Collect validation errors for the playlist.
105
+ # @return [Array<String>] list of error messages
106
+ def errors
107
+ [].tap do |errors|
108
+ validate_mixed_items(errors)
109
+ validate_target_duration(errors)
110
+ validate_segment_items(errors)
111
+ validate_playlist_items(errors)
112
+ validate_media_items(errors)
113
+ validate_key_items(errors)
114
+ validate_session_key_items(errors)
115
+ validate_session_data_items(errors)
116
+ validate_part_items(errors)
117
+ end
118
+ end
119
+
120
+ # Whether the playlist passes all validations.
121
+ # @return [Boolean]
47
122
  def valid?
48
- return false if playlist_size > 0 && segment_size > 0
49
- true
123
+ errors.empty?
124
+ end
125
+
126
+ # @return [Array<SegmentItem>]
127
+ def segments
128
+ items.grep(SegmentItem)
129
+ end
130
+
131
+ # @return [Array<PlaylistItem>]
132
+ def playlists
133
+ items.grep(PlaylistItem)
134
+ end
135
+
136
+ # @return [Array<MediaItem>]
137
+ def media_items
138
+ items.grep(MediaItem)
139
+ end
140
+
141
+ # @return [Array<KeyItem>]
142
+ def keys
143
+ items.grep(KeyItem)
144
+ end
145
+
146
+ # @return [Array<MapItem>]
147
+ def maps
148
+ items.grep(MapItem)
149
+ end
150
+
151
+ # @return [Array<DateRangeItem>]
152
+ def date_ranges
153
+ items.grep(DateRangeItem)
154
+ end
155
+
156
+ # @return [Array<PartItem>]
157
+ def parts
158
+ items.grep(PartItem)
159
+ end
160
+
161
+ # @return [Array<SessionDataItem>]
162
+ def session_data
163
+ items.grep(SessionDataItem)
164
+ end
165
+
166
+ # @return [Array<SessionKeyItem>]
167
+ def session_keys
168
+ items.grep(SessionKeyItem)
50
169
  end
51
170
 
171
+ # Total duration of all segments.
172
+ # @return [Float]
52
173
  def duration
53
- duration = 0.0
54
- items.each do |item|
55
- duration += item.duration if item.is_a?(M3u8::SegmentItem)
56
- end
57
- duration
174
+ segments.sum(&:duration)
58
175
  end
59
176
 
60
177
  private
61
178
 
179
+ def freeze_item(item)
180
+ item.byterange&.freeze if item.respond_to?(:byterange)
181
+ item.program_date_time&.freeze if item.respond_to?(:program_date_time)
182
+ item.client_attributes&.freeze if item.respond_to?(:client_attributes)
183
+ item.freeze
184
+ end
185
+
62
186
  def assign_options(options)
63
187
  options = defaults.merge(options)
64
188
 
@@ -72,6 +196,8 @@ module M3u8
72
196
  @independent_segments = options[:independent_segments]
73
197
  @master = options[:master]
74
198
  @live = options[:live]
199
+ @part_inf = options[:part_inf]
200
+ @server_control = options[:server_control]
75
201
  end
76
202
 
77
203
  def defaults
@@ -84,12 +210,94 @@ module M3u8
84
210
  }
85
211
  end
86
212
 
213
+ def validate_part_items(errors)
214
+ parts.each do |item|
215
+ errors << 'Part item requires a URI' if item.uri.nil?
216
+ errors << 'Part item requires a duration' if item.duration.nil?
217
+ end
218
+ end
219
+
220
+ def validate_session_data_items(errors)
221
+ session_data.each do |item|
222
+ errors << 'Session data item requires a data ID' if item.data_id.nil?
223
+ if !item.value.nil? && !item.uri.nil?
224
+ errors << 'Session data item cannot have both value and URI'
225
+ elsif item.value.nil? && item.uri.nil?
226
+ errors << 'Session data item requires a value or URI'
227
+ end
228
+ end
229
+ end
230
+
231
+ def validate_key_items(errors)
232
+ keys.each do |item|
233
+ next if item.method == 'NONE'
234
+
235
+ next unless item.uri.nil?
236
+
237
+ errors << 'Key item requires a URI ' \
238
+ 'when method is not NONE'
239
+ end
240
+ end
241
+
242
+ def validate_session_key_items(errors)
243
+ session_keys.each do |item|
244
+ next if item.method == 'NONE'
245
+
246
+ next unless item.uri.nil?
247
+
248
+ errors << 'Session key item requires a URI ' \
249
+ 'when method is not NONE'
250
+ end
251
+ end
252
+
253
+ def validate_playlist_items(errors)
254
+ playlists.each do |item|
255
+ unless item.bandwidth&.positive?
256
+ errors << 'Playlist item requires a bandwidth'
257
+ end
258
+ errors << 'Playlist item requires a URI' if item.uri.nil?
259
+ end
260
+ end
261
+
262
+ def validate_media_items(errors)
263
+ media_items.each do |item|
264
+ errors << 'Media item requires a type' if item.type.nil?
265
+ errors << 'Media item requires a group ID' if item.group_id.nil?
266
+ errors << 'Media item requires a name' if item.name.nil?
267
+ end
268
+ end
269
+
270
+ def validate_segment_items(errors)
271
+ segments.each do |segment|
272
+ errors << 'Segment item requires a segment URI' if segment.segment.nil?
273
+ if segment.duration&.negative?
274
+ errors << 'Segment item has negative duration'
275
+ end
276
+ end
277
+ end
278
+
279
+ def validate_target_duration(errors)
280
+ return if master?
281
+
282
+ max = segments.filter_map { |s| s.duration&.round }.max
283
+ return if max.nil? || target >= max
284
+
285
+ errors << "Target duration #{target} is less than " \
286
+ "segment duration of #{max}"
287
+ end
288
+
289
+ def validate_mixed_items(errors)
290
+ return unless playlist_size.positive? && segment_size.positive?
291
+
292
+ errors << 'Playlist contains both master and media items'
293
+ end
294
+
87
295
  def playlist_size
88
- items.count { |item| item.is_a?(PlaylistItem) }
296
+ playlists.size
89
297
  end
90
298
 
91
299
  def segment_size
92
- items.count { |item| item.is_a?(SegmentItem) }
300
+ segments.size
93
301
  end
94
302
  end
95
303
  end