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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +89 -0
- data/lib/m3u8/bitrate_item.rb +2 -0
- data/lib/m3u8/byte_range.rb +2 -0
- data/lib/m3u8/cli/diff_command.rb +61 -0
- data/lib/m3u8/cli/inspect_command.rb +9 -1
- data/lib/m3u8/cli/validate_command.rb +16 -0
- data/lib/m3u8/cli.rb +45 -11
- data/lib/m3u8/content_steering_item.rb +1 -0
- data/lib/m3u8/date_range_item.rb +1 -0
- data/lib/m3u8/define_item.rb +1 -0
- data/lib/m3u8/discontinuity_item.rb +2 -0
- data/lib/m3u8/encryptable.rb +10 -0
- data/lib/m3u8/gap_item.rb +2 -0
- data/lib/m3u8/key_item.rb +1 -8
- data/lib/m3u8/map_item.rb +1 -0
- data/lib/m3u8/media_item.rb +1 -0
- data/lib/m3u8/part_inf_item.rb +1 -0
- data/lib/m3u8/part_item.rb +1 -0
- data/lib/m3u8/playback_start.rb +1 -0
- data/lib/m3u8/playlist.rb +236 -2
- data/lib/m3u8/playlist_item.rb +1 -0
- data/lib/m3u8/preload_hint_item.rb +1 -0
- data/lib/m3u8/reader.rb +15 -2
- data/lib/m3u8/rendition_report_item.rb +1 -0
- data/lib/m3u8/segment_item.rb +1 -0
- data/lib/m3u8/serializable.rb +43 -0
- data/lib/m3u8/server_control_item.rb +1 -0
- data/lib/m3u8/session_data_item.rb +1 -0
- data/lib/m3u8/session_key_item.rb +1 -8
- data/lib/m3u8/skip_item.rb +1 -0
- data/lib/m3u8/time_item.rb +1 -0
- data/lib/m3u8/variable_resolver.rb +83 -0
- data/lib/m3u8/version.rb +1 -1
- data/lib/m3u8.rb +2 -0
- data/spec/lib/m3u8/cli/diff_command_spec.rb +49 -0
- data/spec/lib/m3u8/cli/inspect_command_spec.rb +12 -0
- data/spec/lib/m3u8/cli/validate_command_spec.rb +17 -1
- data/spec/lib/m3u8/cli_spec.rb +47 -1
- data/spec/lib/m3u8/conformance_spec.rb +123 -0
- data/spec/lib/m3u8/reader_spec.rb +35 -0
- data/spec/lib/m3u8/round_trip_spec.rb +110 -0
- data/spec/lib/m3u8/serializable_spec.rb +133 -0
- data/spec/lib/m3u8/time_item_spec.rb +13 -0
- data/spec/lib/m3u8/variable_resolver_spec.rb +104 -0
- 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
|
data/lib/m3u8/playlist_item.rb
CHANGED
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
|
-
|
|
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}-")
|
data/lib/m3u8/segment_item.rb
CHANGED
|
@@ -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,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
|
data/lib/m3u8/skip_item.rb
CHANGED
data/lib/m3u8/time_item.rb
CHANGED
|
@@ -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
data/lib/m3u8.rb
CHANGED
|
@@ -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
|
|
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
|