m3uzi 0.2.1 → 0.4.2

Sign up to get free protection for your applications and to get access to all the features.
data/lib/m3uzi.rb CHANGED
@@ -1,24 +1,20 @@
1
1
  $:<< File.dirname(__FILE__)
2
+ require 'm3uzi/item'
2
3
  require 'm3uzi/tag'
3
4
  require 'm3uzi/file'
4
5
  require 'm3uzi/stream'
6
+ require 'm3uzi/comment'
5
7
  require 'm3uzi/version'
6
8
 
7
9
  class M3Uzi
8
10
 
9
- # Unsupported: PROGRAM-DATE-TIME DISCONTINUITY
10
- VALID_TAGS = %w{TARGETDURATION MEDIA-SEQUENCE ALLOW-CACHE ENDLIST KEY}
11
-
12
- attr_accessor :files, :streams
13
- attr_accessor :tags, :comments
11
+ attr_accessor :header_tags, :playlist_items
14
12
  attr_accessor :final_media_file
15
13
  attr_accessor :version
16
14
 
17
15
  def initialize
18
- @files = []
19
- @streams = []
20
- @tags = []
21
- @comments = []
16
+ @header_tags = {}
17
+ @playlist_items = []
22
18
  @final_media_file = true
23
19
  @version = 1
24
20
  end
@@ -28,78 +24,161 @@ class M3Uzi
28
24
  # Read/Write M3U8 Files
29
25
  #-------------------------------------
30
26
 
31
- def self.read(path)
32
- m3u = self.new
33
- lines = ::File.readlines(path)
34
- lines.each_with_index do |line, i|
35
- case type(line)
36
- when :tag
37
- name, value = parse_general_tag(line)
38
- m3u.add_tag do |tag|
39
- tag.name = name
40
- tag.value = value
41
- end
42
- when :info
43
- duration, description = parse_file_tag(line)
44
- m3u.add_file do |file|
45
- file.path = lines[i+1].strip
46
- file.duration = duration
47
- file.description = description
48
- end
49
- m3u.final_media_file = false
50
- when :stream
51
- attributes = parse_stream_tag(line)
52
- m3u.add_stream do |stream|
53
- stream.path = lines[i+1].strip
54
- attributes.each_pair do |k,v|
55
- k = k.to_s.downcase.sub('-','_')
56
- next unless [:bandwidth, :program_id, :codecs, :resolution].include?(k)
57
- v = $1 if v.to_s =~ /^"(.*)"$/
58
- stream.send("#{k}=", v)
59
- end
60
- end
61
- when :final
62
- m3u.final_media_file = true
63
- else
64
- next
65
- end
66
- end
67
- m3u
68
- end
27
+ ##
28
+ ## For now, reading m3u8 files is not keeping up to date with writing, so we're
29
+ ## disabling it in this version. (Possibly to be re-introduced in the future.)
30
+ ##
31
+ # def self.read(path)
32
+ # m3u = self.new
33
+ # lines = ::File.readlines(path)
34
+ # lines.each_with_index do |line, i|
35
+ # case type(line)
36
+ # when :tag
37
+ # name, value = parse_general_tag(line)
38
+ # m3u.add_tag do |tag|
39
+ # tag.name = name
40
+ # tag.value = value
41
+ # end
42
+ # when :info
43
+ # duration, description = parse_file_tag(line)
44
+ # m3u.add_file do |file|
45
+ # file.path = lines[i+1].strip
46
+ # file.duration = duration
47
+ # file.description = description
48
+ # end
49
+ # m3u.final_media_file = false
50
+ # when :stream
51
+ # attributes = parse_stream_tag(line)
52
+ # m3u.add_stream do |stream|
53
+ # stream.path = lines[i+1].strip
54
+ # attributes.each_pair do |k,v|
55
+ # k = k.to_s.downcase.sub('-','_')
56
+ # next unless [:bandwidth, :program_id, :codecs, :resolution].include?(k)
57
+ # v = $1 if v.to_s =~ /^"(.*)"$/
58
+ # stream.send("#{k}=", v)
59
+ # end
60
+ # end
61
+ # when :final
62
+ # m3u.final_media_file = true
63
+ # else
64
+ # next
65
+ # end
66
+ # end
67
+ # m3u
68
+ # end
69
69
 
70
70
  def write_to_io(io_stream)
71
+ reset_encryption_key_history
72
+ reset_byterange_history
73
+
71
74
  check_version_restrictions
72
75
  io_stream << "#EXTM3U\n"
73
76
  io_stream << "#EXT-X-VERSION:#{@version.to_i}\n" if @version > 1
74
- comments.each do |comment|
75
- io_stream << "##{comment}\n"
77
+
78
+ if items(File).length > 0
79
+ max_duration = valid_items(File).map { |f| f.duration.to_f }.max || 10.0
80
+ io_stream << "#EXT-X-TARGETDURATION:#{max_duration.ceil}\n"
76
81
  end
77
- tags.each do |tag|
78
- next if %w{M3U ENDLIST}.include?(tag.name.to_s.upcase)
79
- if VALID_TAGS.include?(tag.name.to_s.upcase)
80
- io_stream << "#EXT-X-#{tag.name.to_s.upcase}"
81
- else
82
- io_stream << "##{tag.name.to_s.upcase}"
82
+
83
+ @header_tags.each do |item|
84
+ io_stream << (item.format + "\n") if item.valid?
85
+ end
86
+
87
+ @playlist_items.each do |item|
88
+ next unless item.valid?
89
+
90
+ if item.kind_of?(File)
91
+ encryption_key_line = generate_encryption_key_line(item)
92
+ io_stream << (encryption_key_line + "\n") if encryption_key_line
93
+
94
+ byterange_line = generate_byterange_line(item)
95
+ io_stream << (byterange_line + "\n") if byterange_line
83
96
  end
84
- tag.value && io_stream << ":#{tag.value}"
85
- io_stream << "\n"
97
+
98
+ io_stream << (item.format + "\n")
86
99
  end
87
- files.each do |file|
88
- io_stream << "#EXTINF:#{file.attribute_string}"
89
- io_stream << "\n#{file.path}\n"
100
+
101
+ io_stream << "#EXT-X-ENDLIST\n" if items(File).length > 0 && @final_media_file
102
+ end
103
+
104
+ def write(path)
105
+ ::File.open(path, "w") { |f| write_to_io(f) }
106
+ end
107
+
108
+ def items(kind)
109
+ @playlist_items.select { |item| item.kind_of?(kind) }
110
+ end
111
+
112
+ def valid_items(kind)
113
+ @playlist_items.select { |item| item.kind_of?(kind) && item.valid? }
114
+ end
115
+
116
+ #-------------------------------------
117
+ # Playlist generation helpers.
118
+ #-------------------------------------
119
+
120
+ def reset_encryption_key_history
121
+ @encryption_key_url = nil
122
+ @encryption_iv = nil
123
+ @encryption_sequence = 0
124
+ end
125
+
126
+ def generate_encryption_key_line(file)
127
+ generate_line = false
128
+
129
+ default_iv = @encryption_iv || format_iv(@encryption_sequence)
130
+
131
+ if (file.encryption_key_url != :unset) && (file.encryption_key_url != @encryption_key_url)
132
+ @encryption_key_url = file.encryption_key_url
133
+ generate_line = true
90
134
  end
91
- streams.each do |stream|
92
- io_stream << "#EXT-X-STREAM-INF:#{stream.attribute_string}"
93
- io_stream << "\n#{stream.path}\n"
135
+
136
+ if @encryption_key_url && file.encryption_iv != @encryption_iv
137
+ @encryption_iv = file.encryption_iv
138
+ generate_line = true
139
+ end
140
+
141
+ @encryption_sequence += 1
142
+
143
+ if generate_line
144
+ if @encryption_key_url.nil?
145
+ "#EXT-X-KEY:METHOD=NONE"
146
+ else
147
+ attrs = ['METHOD=AES-128']
148
+ attrs << 'URI="' + @encryption_key_url.gsub('"','%22').gsub(/[\r\n]/,'').strip + '"'
149
+ attrs << "IV=#{@encryption_iv}" if @encryption_iv
150
+ '#EXT-X-KEY:' + attrs.join(',')
151
+ end
152
+ else
153
+ nil
94
154
  end
95
- io_stream << "#EXT-X-ENDLIST\n" if files.length > 0 && final_media_file
96
155
  end
97
156
 
98
- def write(path)
99
- check_version_restrictions
100
- f = ::File.open(path, "w")
101
- write_to_io(f)
102
- f.close()
157
+ def reset_byterange_history
158
+ @prev_byterange_endpoint = nil
159
+ end
160
+
161
+ def generate_byterange_line(file)
162
+ line = nil
163
+
164
+ if file.byterange
165
+ if file.byterange_offset && file.byterange_offset != @prev_byterange_endpoint
166
+ offset = file.byterange_offset
167
+ elsif @prev_byterange_endpoint.nil?
168
+ offset = 0
169
+ else
170
+ offset = nil
171
+ end
172
+
173
+ line = "#EXT-X-BYTERANGE:#{file.byterange_offset.to_i}"
174
+ line += "@#{offset}" if offset
175
+
176
+ @prev_byterange_endpoint = offset + file.byterange
177
+ else
178
+ @prev_byterange_endpoint = nil
179
+ end
180
+
181
+ line
103
182
  end
104
183
 
105
184
 
@@ -107,14 +186,16 @@ class M3Uzi
107
186
  # Files
108
187
  #-------------------------------------
109
188
 
110
- def add_file(&block)
189
+ def add_file(path = nil, duration = nil)
111
190
  new_file = M3Uzi::File.new
112
- yield(new_file)
113
- @files << new_file
191
+ new_file.path = path if path
192
+ new_file.duration = duration if duration
193
+ yield(new_file) if block_given?
194
+ @playlist_items << new_file
114
195
  end
115
196
 
116
197
  def filenames
117
- files.map { |file| file.path }
198
+ items(File).map { |file| file.path }
118
199
  end
119
200
 
120
201
 
@@ -122,14 +203,16 @@ class M3Uzi
122
203
  # Streams
123
204
  #-------------------------------------
124
205
 
125
- def add_stream(&block)
206
+ def add_stream(path = nil, bandwidth = nil)
126
207
  new_stream = M3Uzi::Stream.new
127
- yield(new_stream)
128
- @streams << new_stream
208
+ new_stream.path = path
209
+ new_stream.bandwidth = bandwidth
210
+ yield(new_stream) if block_given?
211
+ @playlist_items << new_stream
129
212
  end
130
213
 
131
214
  def stream_names
132
- streams.map { |stream| stream.path }
215
+ items(Stream).map { |stream| stream.path }
133
216
  end
134
217
 
135
218
 
@@ -137,56 +220,65 @@ class M3Uzi
137
220
  # Tags
138
221
  #-------------------------------------
139
222
 
140
- def add_tag(&block)
223
+ def add_tag(name = nil, value = nil)
141
224
  new_tag = M3Uzi::Tag.new
142
- yield(new_tag)
143
- @tags << new_tag
144
- end
145
-
146
- def [](key)
147
- tag_name = key.to_s.upcase.gsub("_", "-")
148
- obj = tags.detect { |tag| tag.name == tag_name }
149
- obj && obj.value
225
+ new_tag.name = name
226
+ new_tag.value = value
227
+ yield(new_tag) if block_given?
228
+ @header_tags[new_tag.name] = new_tag
150
229
  end
151
230
 
152
- def []=(key, value)
153
- add_tag do |tag|
154
- tag.name = key
155
- tag.value = value
156
- end
157
- end
231
+ # def [](key)
232
+ # tag_name = key.to_s.upcase.gsub("_", "-")
233
+ # obj = tags.detect { |tag| tag.name == tag_name }
234
+ # obj && obj.value
235
+ # end
236
+ #
237
+ # def []=(key, value)
238
+ # add_tag do |tag|
239
+ # tag.name = key
240
+ # tag.value = value
241
+ # end
242
+ # end
158
243
 
159
244
 
160
245
  #-------------------------------------
161
246
  # Comments
162
247
  #-------------------------------------
163
248
 
164
- def add_comment(comment)
165
- @comments << comment
249
+ def add_comment(comment = nil)
250
+ new_comment = M3Uzi::Comment.new
251
+ new_comment.text = comment
252
+ yield(new_comment) if block_given?
253
+ @playlist_items << new_comment
166
254
  end
167
255
 
168
- def <<(comment)
169
- add_comment(comment)
170
- end
256
+ # def <<(comment)
257
+ # add_comment(comment)
258
+ # end
171
259
 
172
260
  def check_version_restrictions
173
261
  @version = 1
174
262
 
263
+ #
175
264
  # Version 2 Features
176
- if @tags.detect { |tag| tag.name == 'KEY' && tag.value.to_s =~ /,IV=/ }
265
+ #
266
+
267
+ # Check for custom IV
268
+ if valid_items(File).detect { |item| item.encryption_key_url && item.encryption_iv }
177
269
  @version = 2 if @version < 2
178
270
  end
179
271
 
180
272
  # Version 3 Features
181
- if @files.detect { |file| file.duration.kind_of?(Float) }
273
+ if valid_items(File).detect { |item| item.duration.kind_of?(Float) }
182
274
  @version = 3 if @version < 3
183
275
  end
184
276
 
185
277
  # Version 4 Features
186
- if @files.detect { |file| file.byterange }
278
+ if valid_items(File).detect { |item| item.byterange }
187
279
  @version = 4 if @version < 4
188
280
  end
189
- if @tags.detect { |tag| ['MEDIA','I-FRAMES-ONLY'].include?(tag.name) }
281
+ if valid_items(Tag).detect { |item| ['MEDIA','I-FRAMES-ONLY'].include?(item.name) }
190
282
  @version = 4 if @version < 4
191
283
  end
192
284
 
@@ -199,36 +291,43 @@ class M3Uzi
199
291
 
200
292
  protected
201
293
 
202
- def self.type(line)
203
- case line
204
- when /^\s*$/
205
- :whitespace
206
- when /^#(?!EXT)/
207
- :comment
208
- when /^#EXTINF/
209
- :info
210
- when /^#EXT(-X)?-STREAM-INF/
211
- :stream
212
- when /^#EXT(-X)?-ENDLIST/
213
- :final
214
- when /^#EXT(?!INF)/
215
- :tag
216
- else
217
- :file
218
- end
219
- end
220
-
221
- def self.parse_general_tag(line)
222
- line.match(/^#EXT(?:-X-)?(?!STREAM-INF)([^:\n]+)(:([^\n]+))?$/).values_at(1, 3)
294
+ # def self.type(line)
295
+ # case line
296
+ # when /^\s*$/
297
+ # :whitespace
298
+ # when /^#(?!EXT)/
299
+ # :comment
300
+ # when /^#EXTINF/
301
+ # :info
302
+ # when /^#EXT(-X)?-STREAM-INF/
303
+ # :stream
304
+ # when /^#EXT(-X)?-ENDLIST/
305
+ # :final
306
+ # when /^#EXT(?!INF)/
307
+ # :tag
308
+ # else
309
+ # :file
310
+ # end
311
+ # end
312
+ #
313
+ # def self.parse_general_tag(line)
314
+ # line.match(/^#EXT(?:-X-)?(?!STREAM-INF)([^:\n]+)(:([^\n]+))?$/).values_at(1, 3)
315
+ # end
316
+ #
317
+ # def self.parse_file_tag(line)
318
+ # line.match(/^#EXTINF:[ \t]*(\d+),?[ \t]*(.*)$/).values_at(1, 2)
319
+ # end
320
+ #
321
+ # def self.parse_stream_tag(line)
322
+ # match = line.match(/^#EXT-X-STREAM-INF:(.*)$/)[1]
323
+ # match.scan(/([A-Z-]+)\s*=\s*("[^"]*"|[^,]*)/) # return attributes as array of arrays
324
+ # end
325
+
326
+ def self.format_iv(num)
327
+ '0x' + num.to_s(16).rjust(32,'0')
223
328
  end
224
329
 
225
- def self.parse_file_tag(line)
226
- line.match(/^#EXTINF:[ \t]*(\d+),?[ \t]*(.*)$/).values_at(1, 2)
330
+ def format_iv(num)
331
+ self.class.format_iv(num)
227
332
  end
228
-
229
- def self.parse_stream_tag(line)
230
- match = line.match(/^#EXT-X-STREAM-INF:(.*)$/)[1]
231
- match.scan(/([A-Z-]+)\s*=\s*("[^"]*"|[^,]*)/) # return attributes as array of arrays
232
- end
233
-
234
333
  end
@@ -0,0 +1,10 @@
1
+ class M3Uzi
2
+ class Comment < Item
3
+
4
+ attr_accessor :text
5
+
6
+ def format
7
+ "##{text}"
8
+ end
9
+ end
10
+ end
data/lib/m3uzi/file.rb CHANGED
@@ -1,15 +1,41 @@
1
1
  class M3Uzi
2
- class File
2
+ class File < Item
3
3
 
4
- attr_accessor :path, :duration, :description, :byterange
4
+ attr_accessor :path, :duration, :description, :byterange, :byterange_offset, :encryption_key_url, :encryption_iv
5
+
6
+ # Unsupported tags: PROGRAM-DATE-TIME, DISCONTINUITY, I-FRAMES-ONLY
7
+ # Autogenerated tags: EXTINF, BYTERANGE, KEY, VERSION, TARGETDURATION, ENDLIST
8
+
9
+ def initialize
10
+ @encryption_key_url = :unset
11
+ end
5
12
 
6
13
  def attribute_string
7
14
  if duration.kind_of?(Float)
8
- "#{sprintf('%0.4f',duration)},#{description}"
15
+ "#{sprintf('%0.4f',duration)}," + description.to_s.gsub(/[\r\n]/,' ').strip
9
16
  else
10
- "#{duration},#{description}"
17
+ "#{duration.to_i.round}," + description.to_s.gsub(/[\r\n]/,' ').strip
11
18
  end
12
19
  end
13
20
 
21
+ def format
22
+ # Need to add key info if appropriate?
23
+ "#EXTINF:#{attribute_string}\n#{path}"
24
+ end
25
+
26
+ def encryption_iv=(value)
27
+ if value.to_s =~ /^0x/i
28
+ value = $'
29
+ end
30
+
31
+ if value.kind_of?(String)
32
+ raise "Invalid encryption_iv given" unless value.length <= 32 && value =~ /^[0-9a-f]+$/i
33
+ @encryption_iv = '0x' + value.downcase.rjust(32,'0')
34
+ elsif value.nil?
35
+ @encryption_iv = nil
36
+ else
37
+ @encryption_iv = M3Uzi.format_iv(value.to_i)
38
+ end
39
+ end
14
40
  end
15
41
  end
data/lib/m3uzi/item.rb ADDED
@@ -0,0 +1,9 @@
1
+ class M3Uzi
2
+ class Item
3
+
4
+ def valid?
5
+ true
6
+ end
7
+
8
+ end
9
+ end
data/lib/m3uzi/stream.rb CHANGED
@@ -1,17 +1,27 @@
1
1
  class M3Uzi
2
-
3
- class Stream
2
+ class Stream < Item
4
3
 
5
4
  attr_accessor :path, :bandwidth, :program_id, :codecs, :resolution
6
5
 
6
+ # Unsupported tags: EXT-X-MEDIA, EXT-X-I-FRAME-STREAM-INF
7
+ # Unsupported attributes of EXT-X-STREAM-INF: AUDIO, VIDEO
8
+
7
9
  def attribute_string
8
10
  s = []
9
- s << "PROGRAM-ID=#{program_id || 1}"
10
- s << "BANDWIDTH=#{bandwidth}" if bandwidth
11
+ s << "PROGRAM-ID=#{program_id.to_i || 1}"
12
+ s << "BANDWIDTH=#{bandwidth.to_i}"
11
13
  s << "CODECS=\"#{codecs}\"" if codecs
12
14
  s << "RESOLUTION=#{resolution}" if resolution
13
15
  s.join(',')
14
16
  end
17
+
18
+ def format
19
+ "#EXT-X-STREAM-INF:#{attribute_string}\n#{path}"
20
+ end
21
+
22
+ def valid?
23
+ !!(path && bandwidth)
24
+ end
15
25
  end
16
26
 
17
27
  end
data/lib/m3uzi/tag.rb CHANGED
@@ -1,14 +1,34 @@
1
1
  class M3Uzi
2
-
3
- class Tag
2
+ class Tag < Item
4
3
 
5
4
  attr_reader :name
6
5
  attr_accessor :value
7
6
 
7
+ VALID_TAGS = %w{PLAYLIST-TYPE ALLOW-CACHE}
8
+
9
+ # Unsupported tags: MEDIA-SEQUENCE, I-FRAMES-ONLY, PLAYLIST-TYPE
10
+ # Autogenerated tags: EXTM3U, VERSION, ENDLIST, BYTERANGE, TARGETDURATION
11
+
8
12
  def name=(n)
9
13
  @name = n.to_s.upcase.gsub("_", "-")
10
14
  end
11
15
 
16
+ def format
17
+ string << "#EXT-X-#{name}"
18
+ string << ":#{value}" if value
19
+ string
20
+ end
21
+
22
+ def valid?
23
+ case name
24
+ when 'PLAYLIST-TYPE'
25
+ ['EVENT','VOD'].include?(value)
26
+ when 'ALLOW-CACHE'
27
+ ['YES','NO'].include?(value)
28
+ else
29
+ true
30
+ end
31
+ end
12
32
  end
13
33
 
14
34
  end
data/lib/m3uzi/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class M3Uzi
2
- VERSION = '0.2.1'
2
+ VERSION = '0.4.2'
3
3
  end
data/test/m3uzi_test.rb CHANGED
@@ -12,18 +12,18 @@ class M3UziTest < Test::Unit::TestCase
12
12
  assert_equal M3Uzi, m3u.class
13
13
  end
14
14
 
15
- should "read in an index file" do
16
- m3u = M3Uzi.read(File.join(File.dirname(__FILE__), "fixtures/index.m3u8"))
17
- assert_equal M3Uzi, m3u.class
18
- end
15
+ # should "read in an index file" do
16
+ # m3u = M3Uzi.read(File.join(File.dirname(__FILE__), "fixtures/index.m3u8"))
17
+ # assert_equal M3Uzi, m3u.class
18
+ # end
19
19
  end
20
20
 
21
21
  context "with protocol versions" do
22
22
  should "set version 2 for encryption IV" do
23
23
  m3u = M3Uzi.new
24
- m3u['KEY'] = "0x1234567890abcdef1234567890abcdef"
24
+ m3u.add_file('1.ts',10) { |f| f.encryption_key_url = "key.dat" }
25
25
  assert_equal 1, m3u.check_version_restrictions
26
- m3u['KEY'] = "0x1234567890abcdef1234567890abcdef,IV=0x1234567890abcdef1234567890abcdef"
26
+ m3u.add_file('1.ts',10) { |f| f.encryption_key_url = "key.dat"; f.encryption_iv = "0x1234567890abcdef1234567890abcdef" }
27
27
  assert_equal 2, m3u.check_version_restrictions
28
28
 
29
29
  output_stream = StringIO.new
@@ -53,7 +53,7 @@ class M3UziTest < Test::Unit::TestCase
53
53
 
54
54
  output_stream = StringIO.new
55
55
  m3u.write_to_io(output_stream)
56
- assert output_stream.string =~ /:10\.000,/
56
+ assert output_stream.string =~ /:10\.0000,/
57
57
  assert output_stream.string !~ /:10,/
58
58
  end
59
59
 
metadata CHANGED
@@ -4,9 +4,9 @@ version: !ruby/object:Gem::Version
4
4
  prerelease: false
5
5
  segments:
6
6
  - 0
7
+ - 4
7
8
  - 2
8
- - 1
9
- version: 0.2.1
9
+ version: 0.4.2
10
10
  platform: ruby
11
11
  authors:
12
12
  - Brandon Arbini
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2011-10-26 00:00:00 -05:00
18
+ date: 2011-12-12 00:00:00 -08:00
19
19
  default_executable:
20
20
  dependencies: []
21
21
 
@@ -30,7 +30,9 @@ extensions: []
30
30
  extra_rdoc_files: []
31
31
 
32
32
  files:
33
+ - lib/m3uzi/comment.rb
33
34
  - lib/m3uzi/file.rb
35
+ - lib/m3uzi/item.rb
34
36
  - lib/m3uzi/stream.rb
35
37
  - lib/m3uzi/tag.rb
36
38
  - lib/m3uzi/version.rb