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.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +23 -0
- data/.gitignore +1 -1
- data/.rubocop.yml +31 -0
- data/CHANGELOG.md +107 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +1 -1
- data/README.md +524 -40
- data/Rakefile +1 -0
- data/bin/m3u8 +6 -0
- data/lib/m3u8/attribute_formatter.rb +47 -0
- data/lib/m3u8/bitrate_item.rb +31 -0
- data/lib/m3u8/builder.rb +48 -0
- data/lib/m3u8/byte_range.rb +10 -0
- data/lib/m3u8/cli/inspect_command.rb +97 -0
- data/lib/m3u8/cli/validate_command.rb +24 -0
- data/lib/m3u8/cli.rb +116 -0
- data/lib/m3u8/codecs.rb +89 -0
- data/lib/m3u8/content_steering_item.rb +45 -0
- data/lib/m3u8/date_range_item.rb +135 -64
- data/lib/m3u8/define_item.rb +54 -0
- data/lib/m3u8/discontinuity_item.rb +3 -0
- data/lib/m3u8/encryptable.rb +27 -30
- data/lib/m3u8/error.rb +1 -0
- data/lib/m3u8/gap_item.rb +14 -0
- data/lib/m3u8/key_item.rb +7 -0
- data/lib/m3u8/map_item.rb +16 -5
- data/lib/m3u8/media_item.rb +48 -76
- data/lib/m3u8/part_inf_item.rb +35 -0
- data/lib/m3u8/part_item.rb +67 -0
- data/lib/m3u8/playback_start.rb +19 -12
- data/lib/m3u8/playlist.rb +221 -13
- data/lib/m3u8/playlist_item.rb +128 -124
- data/lib/m3u8/preload_hint_item.rb +54 -0
- data/lib/m3u8/reader.rb +86 -28
- data/lib/m3u8/rendition_report_item.rb +48 -0
- data/lib/m3u8/scte35.rb +130 -0
- data/lib/m3u8/scte35_bit_reader.rb +51 -0
- data/lib/m3u8/scte35_segmentation_descriptor.rb +54 -0
- data/lib/m3u8/scte35_splice_insert.rb +62 -0
- data/lib/m3u8/scte35_splice_null.rb +8 -0
- data/lib/m3u8/scte35_time_signal.rb +19 -0
- data/lib/m3u8/segment_item.rb +37 -3
- data/lib/m3u8/server_control_item.rb +69 -0
- data/lib/m3u8/session_data_item.rb +17 -28
- data/lib/m3u8/session_key_item.rb +8 -1
- data/lib/m3u8/skip_item.rb +48 -0
- data/lib/m3u8/time_item.rb +10 -0
- data/lib/m3u8/version.rb +1 -1
- data/lib/m3u8/writer.rb +24 -1
- data/lib/m3u8.rb +30 -6
- data/m3u8.gemspec +12 -12
- data/spec/fixtures/content_steering.m3u8 +10 -0
- data/spec/fixtures/daterange_playlist.m3u8 +14 -0
- data/spec/fixtures/encrypted_discontinuity.m3u8 +17 -0
- data/spec/fixtures/event_playlist.m3u8 +18 -0
- data/spec/fixtures/gap_playlist.m3u8 +14 -0
- data/spec/fixtures/ll_hls_advanced.m3u8 +18 -0
- data/spec/fixtures/ll_hls_playlist.m3u8 +20 -0
- data/spec/fixtures/master_full.m3u8 +14 -0
- data/spec/fixtures/master_v13.m3u8 +8 -0
- data/spec/lib/m3u8/bitrate_item_spec.rb +26 -0
- data/spec/lib/m3u8/builder_spec.rb +352 -0
- data/spec/lib/m3u8/byte_range_spec.rb +1 -0
- data/spec/lib/m3u8/cli/inspect_command_spec.rb +102 -0
- data/spec/lib/m3u8/cli/validate_command_spec.rb +39 -0
- data/spec/lib/m3u8/cli_spec.rb +104 -0
- data/spec/lib/m3u8/content_steering_item_spec.rb +56 -0
- data/spec/lib/m3u8/date_range_item_spec.rb +159 -31
- data/spec/lib/m3u8/define_item_spec.rb +59 -0
- data/spec/lib/m3u8/discontinuity_item_spec.rb +1 -0
- data/spec/lib/m3u8/gap_item_spec.rb +12 -0
- data/spec/lib/m3u8/key_item_spec.rb +1 -0
- data/spec/lib/m3u8/map_item_spec.rb +1 -0
- data/spec/lib/m3u8/media_item_spec.rb +34 -0
- data/spec/lib/m3u8/part_inf_item_spec.rb +27 -0
- data/spec/lib/m3u8/part_item_spec.rb +67 -0
- data/spec/lib/m3u8/playback_start_spec.rb +4 -5
- data/spec/lib/m3u8/playlist_item_spec.rb +130 -17
- data/spec/lib/m3u8/playlist_spec.rb +545 -13
- data/spec/lib/m3u8/preload_hint_item_spec.rb +57 -0
- data/spec/lib/m3u8/reader_spec.rb +376 -29
- data/spec/lib/m3u8/rendition_report_item_spec.rb +56 -0
- data/spec/lib/m3u8/round_trip_spec.rb +152 -0
- data/spec/lib/m3u8/scte35_bit_reader_spec.rb +106 -0
- data/spec/lib/m3u8/scte35_segmentation_descriptor_spec.rb +143 -0
- data/spec/lib/m3u8/scte35_spec.rb +94 -0
- data/spec/lib/m3u8/scte35_splice_insert_spec.rb +185 -0
- data/spec/lib/m3u8/scte35_splice_null_spec.rb +12 -0
- data/spec/lib/m3u8/scte35_time_signal_spec.rb +50 -0
- data/spec/lib/m3u8/segment_item_spec.rb +47 -0
- data/spec/lib/m3u8/server_control_item_spec.rb +64 -0
- data/spec/lib/m3u8/session_data_item_spec.rb +1 -0
- data/spec/lib/m3u8/session_key_item_spec.rb +1 -0
- data/spec/lib/m3u8/skip_item_spec.rb +48 -0
- data/spec/lib/m3u8/time_item_spec.rb +1 -0
- data/spec/lib/m3u8/writer_spec.rb +69 -30
- data/spec/lib/m3u8_spec.rb +1 -0
- data/spec/spec_helper.rb +4 -87
- metadata +70 -129
- data/.hound.yml +0 -3
- data/.travis.yml +0 -8
- data/Guardfile +0 -6
data/lib/m3u8/playlist_item.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
+
video = Codecs.video_codec(profile, level)
|
|
40
73
|
|
|
41
|
-
# profile and/or level were specified but not recognized
|
|
42
|
-
|
|
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
|
-
|
|
77
|
+
audio = Codecs.audio_codec(@audio_codec)
|
|
46
78
|
|
|
47
|
-
# audio codec was specified but not recognized
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
104
|
+
bandwidth:
|
|
105
|
+
parse_bandwidth(attributes['BANDWIDTH']),
|
|
68
106
|
average_bandwidth:
|
|
69
|
-
parse_average_bandwidth(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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'],
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' =>
|
|
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' =>
|
|
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
|
|
115
|
+
playlist.type = tag_value(line)
|
|
92
116
|
end
|
|
93
117
|
|
|
94
118
|
def parse_version(line)
|
|
95
|
-
playlist.version = line
|
|
119
|
+
playlist.version = tag_value(line).to_i
|
|
96
120
|
end
|
|
97
121
|
|
|
98
122
|
def parse_sequence(line)
|
|
99
|
-
playlist.sequence = line
|
|
123
|
+
playlist.sequence = tag_value(line).to_i
|
|
100
124
|
end
|
|
101
125
|
|
|
102
126
|
def parse_cache(line)
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|