m3u8 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +89 -0
  4. data/lib/m3u8/bitrate_item.rb +2 -0
  5. data/lib/m3u8/byte_range.rb +2 -0
  6. data/lib/m3u8/cli/diff_command.rb +61 -0
  7. data/lib/m3u8/cli/inspect_command.rb +9 -1
  8. data/lib/m3u8/cli/validate_command.rb +16 -0
  9. data/lib/m3u8/cli.rb +45 -11
  10. data/lib/m3u8/content_steering_item.rb +1 -0
  11. data/lib/m3u8/date_range_item.rb +1 -0
  12. data/lib/m3u8/define_item.rb +1 -0
  13. data/lib/m3u8/discontinuity_item.rb +2 -0
  14. data/lib/m3u8/encryptable.rb +10 -0
  15. data/lib/m3u8/gap_item.rb +2 -0
  16. data/lib/m3u8/key_item.rb +1 -8
  17. data/lib/m3u8/map_item.rb +1 -0
  18. data/lib/m3u8/media_item.rb +1 -0
  19. data/lib/m3u8/part_inf_item.rb +1 -0
  20. data/lib/m3u8/part_item.rb +1 -0
  21. data/lib/m3u8/playback_start.rb +1 -0
  22. data/lib/m3u8/playlist.rb +236 -2
  23. data/lib/m3u8/playlist_item.rb +1 -0
  24. data/lib/m3u8/preload_hint_item.rb +1 -0
  25. data/lib/m3u8/reader.rb +15 -2
  26. data/lib/m3u8/rendition_report_item.rb +1 -0
  27. data/lib/m3u8/segment_item.rb +1 -0
  28. data/lib/m3u8/serializable.rb +43 -0
  29. data/lib/m3u8/server_control_item.rb +1 -0
  30. data/lib/m3u8/session_data_item.rb +1 -0
  31. data/lib/m3u8/session_key_item.rb +1 -8
  32. data/lib/m3u8/skip_item.rb +1 -0
  33. data/lib/m3u8/time_item.rb +1 -0
  34. data/lib/m3u8/variable_resolver.rb +83 -0
  35. data/lib/m3u8/version.rb +1 -1
  36. data/lib/m3u8.rb +2 -0
  37. data/spec/lib/m3u8/cli/diff_command_spec.rb +49 -0
  38. data/spec/lib/m3u8/cli/inspect_command_spec.rb +12 -0
  39. data/spec/lib/m3u8/cli/validate_command_spec.rb +17 -1
  40. data/spec/lib/m3u8/cli_spec.rb +47 -1
  41. data/spec/lib/m3u8/conformance_spec.rb +123 -0
  42. data/spec/lib/m3u8/reader_spec.rb +35 -0
  43. data/spec/lib/m3u8/round_trip_spec.rb +110 -0
  44. data/spec/lib/m3u8/serializable_spec.rb +133 -0
  45. data/spec/lib/m3u8/time_item_spec.rb +13 -0
  46. data/spec/lib/m3u8/variable_resolver_spec.rb +104 -0
  47. metadata +9 -2
data/lib/m3u8/playlist.rb CHANGED
@@ -4,6 +4,8 @@ module M3u8
4
4
  # Playlist represents an m3u8 playlist, it can be a master playlist
5
5
  # or a set of media segments
6
6
  class Playlist
7
+ include Serializable
8
+
7
9
  # @return [Array] list of items in the playlist
8
10
  # @return [Integer, nil] EXT-X-VERSION value
9
11
  # @return [Boolean, nil] EXT-X-ALLOW-CACHE value
@@ -21,10 +23,14 @@ module M3u8
21
23
  :independent_segments, :live, :part_inf,
22
24
  :server_control
23
25
 
26
+ # @return [Array<String>] unrecognized #EXT tags found while parsing
27
+ attr_reader :unknown_tags
28
+
24
29
  # @param options [Hash] playlist attributes
25
30
  def initialize(options = {})
26
31
  assign_options(options)
27
32
  @items = []
33
+ @unknown_tags = []
28
34
  end
29
35
 
30
36
  # Build a playlist using a DSL block.
@@ -52,12 +58,63 @@ module M3u8
52
58
 
53
59
  # Parse an m3u8 playlist from a String or IO.
54
60
  # @param input [String, IO] playlist content
61
+ # @param strict [Boolean] raise on unsupported tags when true
55
62
  # @return [Playlist] frozen playlist
56
- def self.read(input)
57
- reader = Reader.new
63
+ def self.read(input, strict: false)
64
+ reader = Reader.new(strict: strict)
58
65
  reader.read(input)
59
66
  end
60
67
 
68
+ # Reconstruct a playlist from a Hash produced by #to_h.
69
+ # @param hash [Hash] playlist attributes and items
70
+ # @return [Playlist]
71
+ def self.from_h(hash)
72
+ hash = hash.transform_keys(&:to_sym)
73
+ items = hash.delete(:items) || []
74
+ part_inf = hash.delete(:part_inf)
75
+ server_control = hash.delete(:server_control)
76
+ playlist = new(hash)
77
+ playlist.part_inf = nested(PartInfItem, part_inf)
78
+ playlist.server_control = nested(ServerControlItem, server_control)
79
+ items.each { |attributes| playlist.items << item_from_h(attributes) }
80
+ playlist
81
+ end
82
+
83
+ def self.nested(klass, attributes)
84
+ return if attributes.nil?
85
+
86
+ klass.new(attributes.transform_keys(&:to_sym))
87
+ end
88
+ private_class_method :nested
89
+
90
+ def self.item_from_h(attributes)
91
+ attributes = attributes.transform_keys(&:to_sym)
92
+ klass = item_class(attributes.delete(:item_type))
93
+ return klass.new if attributes.empty?
94
+
95
+ klass.new(attributes)
96
+ end
97
+ private_class_method :item_from_h
98
+
99
+ def self.item_class(type)
100
+ klass = serializable_item_classes[type.to_s]
101
+ return klass unless klass.nil?
102
+
103
+ raise InvalidPlaylistError, "Unknown item type: #{type.inspect}"
104
+ end
105
+ private_class_method :item_class
106
+
107
+ def self.serializable_item_classes
108
+ @serializable_item_classes ||=
109
+ M3u8.constants.each_with_object({}) do |name, map|
110
+ const = M3u8.const_get(name)
111
+ next unless const.is_a?(Class) && const.include?(Serializable)
112
+
113
+ map[name.to_s] = const
114
+ end
115
+ end
116
+ private_class_method :serializable_item_classes
117
+
61
118
  # Write the playlist to an IO object.
62
119
  # @param output [IO] writable IO object
63
120
  # @return [void]
@@ -66,6 +123,14 @@ module M3u8
66
123
  writer.write(self)
67
124
  end
68
125
 
126
+ # Resolve EXT-X-DEFINE variable references into a new Playlist.
127
+ # @param imported [Hash] values for IMPORT definitions
128
+ # @param query [Hash] values for QUERYPARAM definitions
129
+ # @return [Playlist]
130
+ def resolve_variables(imported: {}, query: {})
131
+ VariableResolver.new(self, imported: imported, query: query).resolve
132
+ end
133
+
69
134
  # Whether this is a live (non-VOD) media playlist.
70
135
  # @return [Boolean]
71
136
  def live?
@@ -88,6 +153,7 @@ module M3u8
88
153
  def freeze
89
154
  items.each { |item| freeze_item(item) }
90
155
  items.freeze
156
+ unknown_tags.freeze
91
157
  part_inf&.freeze
92
158
  server_control&.freeze
93
159
  super
@@ -101,6 +167,27 @@ module M3u8
101
167
  output.string
102
168
  end
103
169
 
170
+ # Convert the playlist into a Hash of its attributes and items.
171
+ # Each item Hash carries an :item_type key identifying its class.
172
+ # @return [Hash<Symbol, Object>]
173
+ def to_h
174
+ {
175
+ master: master?,
176
+ live: live?,
177
+ version: version,
178
+ independent_segments: independent_segments,
179
+ iframes_only: iframes_only,
180
+ type: type,
181
+ target: target,
182
+ sequence: sequence,
183
+ discontinuity_sequence: discontinuity_sequence,
184
+ cache: cache,
185
+ part_inf: part_inf&.to_h,
186
+ server_control: server_control&.to_h,
187
+ items: items.map { |item| item_to_h(item) }
188
+ }
189
+ end
190
+
104
191
  # Collect validation errors for the playlist.
105
192
  # @return [Array<String>] list of error messages
106
193
  def errors
@@ -123,6 +210,16 @@ module M3u8
123
210
  errors.empty?
124
211
  end
125
212
 
213
+ # Collect non-fatal conformance warnings (version compatibility and
214
+ # Low-Latency HLS recommendations).
215
+ # @return [Array<String>] list of warning messages
216
+ def warnings
217
+ [].tap do |list|
218
+ version_warnings(list)
219
+ low_latency_warnings(list)
220
+ end
221
+ end
222
+
126
223
  # @return [Array<SegmentItem>]
127
224
  def segments
128
225
  items.grep(SegmentItem)
@@ -176,6 +273,10 @@ module M3u8
176
273
 
177
274
  private
178
275
 
276
+ def item_to_h(item)
277
+ item.to_h.merge(item_type: item.class.name.split('::').last)
278
+ end
279
+
179
280
  def freeze_item(item)
180
281
  item.byterange&.freeze if item.respond_to?(:byterange)
181
282
  item.program_date_time&.freeze if item.respond_to?(:program_date_time)
@@ -292,6 +393,139 @@ module M3u8
292
393
  errors << 'Playlist contains both master and media items'
293
394
  end
294
395
 
396
+ VERSION_FEATURES = [
397
+ [2, 'EXT-X-KEY IV', :encryption_iv?],
398
+ [3, 'floating-point EXTINF', :float_durations?],
399
+ [4, 'EXT-X-BYTERANGE', :byterange_segments?],
400
+ [4, 'EXT-X-I-FRAMES-ONLY', :iframes_only],
401
+ [5, 'KEYFORMAT', :key_format?],
402
+ [5, 'EXT-X-MAP', :iframe_map?],
403
+ [6, 'EXT-X-MAP', :media_map?],
404
+ [7, 'SERVICE INSTREAM-ID', :service_instream_id?],
405
+ [7, 'EXT-X-SESSION-KEY', :session_keys?],
406
+ [8, 'EXT-X-DEFINE', :defines?],
407
+ [9, 'EXT-X-SERVER-CONTROL', :server_control?],
408
+ [9, 'EXT-X-PART-INF', :part_inf?],
409
+ [9, 'EXT-X-PART', :parts?],
410
+ [9, 'EXT-X-SKIP', :skips?],
411
+ [9, 'EXT-X-PRELOAD-HINT', :preload_hints?],
412
+ [9, 'EXT-X-RENDITION-REPORT', :rendition_reports?]
413
+ ].freeze
414
+ private_constant :VERSION_FEATURES
415
+
416
+ def version_warnings(list)
417
+ VERSION_FEATURES.each do |required, feature, predicate|
418
+ next unless send(predicate)
419
+ next if effective_version >= required
420
+
421
+ list << "#{feature} requires version #{required} (#{declared_version})"
422
+ end
423
+ end
424
+
425
+ def effective_version
426
+ version || 1
427
+ end
428
+
429
+ def declared_version
430
+ version.nil? ? 'no EXT-X-VERSION tag' : "version #{version}"
431
+ end
432
+
433
+ def encryption_iv?
434
+ (keys + session_keys).any?(&:iv)
435
+ end
436
+
437
+ def key_format?
438
+ (keys + session_keys).any? do |item|
439
+ item.key_format || item.key_format_versions
440
+ end
441
+ end
442
+
443
+ def float_durations?
444
+ segments.any? { |item| item.duration.is_a?(Float) }
445
+ end
446
+
447
+ def byterange_segments?
448
+ segments.any?(&:byterange)
449
+ end
450
+
451
+ def iframe_map?
452
+ maps.any? && iframes_only
453
+ end
454
+
455
+ def media_map?
456
+ maps.any? && !iframes_only
457
+ end
458
+
459
+ def defines?
460
+ items.grep(DefineItem).any?
461
+ end
462
+
463
+ def service_instream_id?
464
+ media_items.any? { |item| item.instream_id&.start_with?('SERVICE') }
465
+ end
466
+
467
+ def session_keys?
468
+ session_keys.any?
469
+ end
470
+
471
+ def server_control?
472
+ !server_control.nil?
473
+ end
474
+
475
+ def part_inf?
476
+ !part_inf.nil?
477
+ end
478
+
479
+ def parts?
480
+ parts.any?
481
+ end
482
+
483
+ def skips?
484
+ items.grep(SkipItem).any?
485
+ end
486
+
487
+ def preload_hints?
488
+ items.grep(PreloadHintItem).any?
489
+ end
490
+
491
+ def rendition_reports?
492
+ items.grep(RenditionReportItem).any?
493
+ end
494
+
495
+ def low_latency_warnings(list)
496
+ if parts.any? && part_inf.nil?
497
+ list << 'EXT-X-PART requires EXT-X-PART-INF'
498
+ end
499
+ part_hold_back_warnings(list) unless part_inf.nil?
500
+ skip_warnings(list)
501
+ end
502
+
503
+ def part_hold_back_warnings(list)
504
+ hold_back = server_control&.part_hold_back
505
+ return if hold_back.nil?
506
+
507
+ target = part_inf.part_target
508
+ return if target.nil?
509
+
510
+ compare_part_hold_back(list, hold_back, target)
511
+ end
512
+
513
+ def compare_part_hold_back(list, hold_back, target)
514
+ if hold_back < target * 2
515
+ list << 'PART-HOLD-BACK should be at least twice PART-TARGET'
516
+ elsif hold_back < target * 3
517
+ list << 'PART-HOLD-BACK should be at least three times PART-TARGET'
518
+ end
519
+ end
520
+
521
+ def skip_warnings(list)
522
+ skip = server_control&.can_skip_until
523
+ return if skip.nil?
524
+ return if skip >= target * 6
525
+
526
+ list << 'CAN-SKIP-UNTIL should be at least six times TARGETDURATION'
527
+ end
528
+
295
529
  def playlist_size
296
530
  playlists.size
297
531
  end
@@ -5,6 +5,7 @@ module M3u8
5
5
  # EXT-X-I-FRAME-STREAM-INF attributes
6
6
  class PlaylistItem
7
7
  extend M3u8
8
+ include Serializable
8
9
  include AttributeFormatter
9
10
 
10
11
  # @return [String, nil] program ID
@@ -5,6 +5,7 @@ module M3u8
5
5
  # a server to indicate a resource that will be needed soon.
6
6
  class PreloadHintItem
7
7
  extend M3u8
8
+ include Serializable
8
9
  include AttributeFormatter
9
10
 
10
11
  # @return [String, nil] hint type (PART or MAP)
data/lib/m3u8/reader.rb CHANGED
@@ -5,7 +5,8 @@ module M3u8
5
5
  class Reader
6
6
  attr_accessor :playlist, :item, :open, :master, :tags
7
7
 
8
- def initialize(*)
8
+ def initialize(strict: false)
9
+ @strict = strict
9
10
  @has_endlist = false
10
11
  @tags = [basic_tags,
11
12
  media_segment_tags,
@@ -22,7 +23,10 @@ module M3u8
22
23
  @has_endlist = false
23
24
  lines = input.is_a?(String) ? input : input.read
24
25
  lines.split(/\r\n|\r|\n/).each_with_index do |line, index|
25
- validate_file_format(line) if index.zero?
26
+ if index.zero?
27
+ validate_file_format(line)
28
+ next
29
+ end
26
30
  parse_line(line)
27
31
  end
28
32
  playlist.live = !@has_endlist unless master
@@ -92,10 +96,19 @@ module M3u8
92
96
 
93
97
  def parse_line(line)
94
98
  return if match_tag(line)
99
+ return handle_unmatched(line) if line.start_with?('#')
95
100
 
96
101
  parse_next_line(line) if !item.nil? && open
97
102
  end
98
103
 
104
+ def handle_unmatched(line)
105
+ return unless line.start_with?('#EXT')
106
+
107
+ raise InvalidPlaylistError, "Unsupported tag: #{line}" if @strict
108
+
109
+ playlist.unknown_tags << line
110
+ end
111
+
99
112
  def match_tag(line)
100
113
  tag = @tags.select do |key|
101
114
  line.start_with?(key) && !line.start_with?("#{key}-")
@@ -5,6 +5,7 @@ module M3u8
5
5
  # carries information about associated renditions in LL-HLS.
6
6
  class RenditionReportItem
7
7
  extend M3u8
8
+ include Serializable
8
9
  include AttributeFormatter
9
10
 
10
11
  # @return [String, nil] rendition URI
@@ -6,6 +6,7 @@ module M3u8
6
6
  class SegmentItem
7
7
  include M3u8
8
8
  include AttributeFormatter
9
+ include Serializable
9
10
 
10
11
  # @return [Float, nil] segment duration in seconds
11
12
  # @return [String, nil] segment URI
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # Serializable provides a Hash representation of an item by converting
5
+ # its instance variables into JSON-compatible values.
6
+ module Serializable
7
+ # Convert the item into a Hash of its attributes.
8
+ # @return [Hash<Symbol, Object>]
9
+ def to_h
10
+ instance_variables.each_with_object({}) do |ivar, result|
11
+ key = ivar.to_s.delete_prefix('@').to_sym
12
+ result[key] = Serializable.serialize(instance_variable_get(ivar))
13
+ end
14
+ end
15
+
16
+ # Convert the item into a JSON-ready Hash (alias of #to_h).
17
+ # @return [Hash]
18
+ def as_json(*)
19
+ to_h
20
+ end
21
+
22
+ # Render the item as a JSON string.
23
+ # @return [String]
24
+ def to_json(*args)
25
+ require 'json'
26
+ to_h.to_json(*args)
27
+ end
28
+
29
+ # Recursively convert a value into a JSON-compatible form.
30
+ # @param value [Object] value to convert
31
+ # @return [Object]
32
+ def self.serialize(value)
33
+ case value
34
+ when TimeItem then serialize(value.time)
35
+ when Time then value.iso8601
36
+ when BigDecimal then value.to_f
37
+ when Serializable then value.to_h
38
+ when Hash then value.transform_values { |item| serialize(item) }
39
+ else value
40
+ end
41
+ end
42
+ end
43
+ end
@@ -5,6 +5,7 @@ module M3u8
5
5
  # provides directives for Low-Latency HLS delivery.
6
6
  class ServerControlItem
7
7
  extend M3u8
8
+ include Serializable
8
9
  include AttributeFormatter
9
10
 
10
11
  # @return [Float, nil] skip threshold in seconds
@@ -4,6 +4,7 @@ module M3u8
4
4
  # SessionDataItem represents a set of EXT-X-SESSION-DATA attributes
5
5
  class SessionDataItem
6
6
  extend M3u8
7
+ include Serializable
7
8
  include AttributeFormatter
8
9
 
9
10
  # @return [String, nil] DATA-ID value
@@ -5,14 +5,7 @@ module M3u8
5
5
  class SessionKeyItem
6
6
  include Encryptable
7
7
  extend M3u8
8
-
9
- # @param params [Hash] attribute key-value pairs
10
- def initialize(params = {})
11
- options = convert_key_names(params)
12
- options.merge(params).each do |key, value|
13
- instance_variable_set("@#{key}", value)
14
- end
15
- end
8
+ include Serializable
16
9
 
17
10
  # Parse an EXT-X-SESSION-KEY tag.
18
11
  # @param text [String] raw tag line
@@ -5,6 +5,7 @@ module M3u8
5
5
  # Updates for Low-Latency HLS.
6
6
  class SkipItem
7
7
  extend M3u8
8
+ include Serializable
8
9
  include AttributeFormatter
9
10
 
10
11
  # @return [Integer, nil] number of skipped segments
@@ -4,6 +4,7 @@ module M3u8
4
4
  # TimeItem represents EXT-X-PROGRAM-DATE-TIME
5
5
  class TimeItem
6
6
  extend M3u8
7
+ include Serializable
7
8
 
8
9
  # @return [Time, String, nil] program date-time value
9
10
  attr_accessor :time
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M3u8
4
+ # VariableResolver applies EXT-X-DEFINE variable substitution to a
5
+ # playlist, replacing {$NAME} references in URIs and string attribute
6
+ # values with their defined values per RFC 8216bis section 4.2. A
7
+ # definition must appear before any reference that uses it.
8
+ class VariableResolver
9
+ VARIABLE_REFERENCE = /\{\$([a-zA-Z0-9_-]+)\}/
10
+
11
+ # @param playlist [Playlist] playlist to resolve
12
+ # @param imported [Hash] values for IMPORT definitions
13
+ # @param query [Hash] values for QUERYPARAM definitions
14
+ def initialize(playlist, imported: {}, query: {})
15
+ @playlist = playlist
16
+ @imported = imported
17
+ @query = query
18
+ end
19
+
20
+ # Resolve variable references into a new Playlist.
21
+ # @return [Playlist]
22
+ def resolve
23
+ hash = @playlist.to_h
24
+ hash[:items] = resolve_items(hash[:items])
25
+ Playlist.from_h(hash)
26
+ end
27
+
28
+ private
29
+
30
+ def resolve_items(items)
31
+ defined = {}
32
+ items.map do |item|
33
+ if item[:item_type] == 'DefineItem'
34
+ register(item, defined)
35
+ item
36
+ else
37
+ substitute(item, defined)
38
+ end
39
+ end
40
+ end
41
+
42
+ def register(item, defined)
43
+ name, value = binding_for(item)
44
+ defined[name] = value
45
+ end
46
+
47
+ def binding_for(define)
48
+ if define[:import]
49
+ external(define[:import], @imported, 'IMPORT')
50
+ elsif define[:queryparam]
51
+ external(define[:queryparam], @query, 'QUERYPARAM')
52
+ else
53
+ [define[:name], define[:value]]
54
+ end
55
+ end
56
+
57
+ def external(name, source, kind)
58
+ unless source.key?(name)
59
+ raise InvalidPlaylistError, "#{kind} variable not found: #{name}"
60
+ end
61
+
62
+ [name, source[name]]
63
+ end
64
+
65
+ def substitute(value, defined)
66
+ case value
67
+ when String then expand(value, defined)
68
+ when Hash
69
+ value.transform_values { |item| substitute(item, defined) }
70
+ else value
71
+ end
72
+ end
73
+
74
+ def expand(string, defined)
75
+ string.gsub(VARIABLE_REFERENCE) do
76
+ name = Regexp.last_match(1)
77
+ defined.fetch(name) do
78
+ raise InvalidPlaylistError, "Undefined variable: #{name}"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
data/lib/m3u8/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  # M3u8 provides parsing, generation, and validation of m3u8 playlists
4
4
  module M3u8
5
- VERSION = '1.8.1'
5
+ VERSION = '1.9.0'
6
6
  end
data/lib/m3u8.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'bigdecimal'
4
4
  require 'stringio'
5
+ require 'time'
6
+ require_relative 'm3u8/serializable'
5
7
  Dir["#{File.dirname(__FILE__)}/m3u8/*.rb"].each { |file| require file }
6
8
 
7
9
  # M3u8 provides parsing, generation, and validation of m3u8 playlists
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe M3u8::CLI::DiffCommand do
6
+ let(:stdout) { StringIO.new }
7
+
8
+ def diff(first, second)
9
+ described_class.new(first, second, stdout).run
10
+ end
11
+
12
+ def media(version: 3, durations: [5.0])
13
+ playlist = M3u8::Playlist.new(version: version, target: 10)
14
+ durations.each do |duration|
15
+ playlist.items << M3u8::SegmentItem.new(duration: duration,
16
+ segment: 'segment.ts')
17
+ end
18
+ playlist
19
+ end
20
+
21
+ it 'reports identical playlists and exits 0' do
22
+ expect(diff(media, media)).to eq(0)
23
+ expect(stdout.string.strip).to eq('Identical')
24
+ end
25
+
26
+ it 'reports a changed top-level attribute' do
27
+ code = diff(media(version: 3), media(version: 4))
28
+ expect(code).to eq(1)
29
+ expect(stdout.string).to include('version: 3 => 4')
30
+ end
31
+
32
+ it 'reports a changed item attribute' do
33
+ code = diff(media(durations: [5.0]), media(durations: [6.0]))
34
+ expect(code).to eq(1)
35
+ expect(stdout.string).to include('items[0].duration: 5.0 => 6.0')
36
+ end
37
+
38
+ it 'reports an added item' do
39
+ code = diff(media(durations: [5.0]), media(durations: [5.0, 6.0]))
40
+ expect(code).to eq(1)
41
+ expect(stdout.string).to include('items[1]: added SegmentItem')
42
+ end
43
+
44
+ it 'reports a removed item' do
45
+ code = diff(media(durations: [5.0, 6.0]), media(durations: [5.0]))
46
+ expect(code).to eq(1)
47
+ expect(stdout.string).to include('items[1]: removed SegmentItem')
48
+ end
49
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'json'
4
5
 
5
6
  describe M3u8::CLI::InspectCommand do
6
7
  let(:stdout) { StringIO.new }
@@ -12,6 +13,17 @@ describe M3u8::CLI::InspectCommand do
12
13
  described_class.new(playlist, stdout).run
13
14
  end
14
15
 
16
+ describe 'json output' do
17
+ it 'prints the playlist as JSON when json is true' do
18
+ playlist = M3u8::Playlist.read(
19
+ File.read('spec/fixtures/master.m3u8')
20
+ )
21
+ code = described_class.new(playlist, stdout, json: true).run
22
+ expect(code).to eq(0)
23
+ expect(JSON.parse(stdout.string)['master']).to be(true)
24
+ end
25
+ end
26
+
15
27
  describe 'media playlist' do
16
28
  it 'displays metadata for a VOD playlist' do
17
29
  code = inspect_fixture('playlist.m3u8')
@@ -13,7 +13,7 @@ describe M3u8::CLI::ValidateCommand do
13
13
  )
14
14
  code = described_class.new(playlist, stdout).run
15
15
  expect(code).to eq(0)
16
- expect(stdout.string.strip).to eq('Valid')
16
+ expect(stdout.string).to start_with('Valid')
17
17
  end
18
18
  end
19
19
 
@@ -35,5 +35,21 @@ describe M3u8::CLI::ValidateCommand do
35
35
  )
36
36
  end
37
37
  end
38
+
39
+ context 'when playlist has warnings' do
40
+ it 'prints warnings after the validity result' do
41
+ playlist = M3u8::Playlist.read(
42
+ File.read('spec/fixtures/map_playlist.m3u8')
43
+ )
44
+ code = described_class.new(playlist, stdout).run
45
+ expect(code).to eq(0)
46
+ lines = stdout.string.split("\n")
47
+ expect(lines).to include('Valid')
48
+ expect(lines).to include('Warnings:')
49
+ expect(lines).to include(
50
+ ' - EXT-X-MAP requires version 6 (version 5)'
51
+ )
52
+ end
53
+ end
38
54
  end
39
55
  end